diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d0007ec516686..2d90fd396eb06 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1123,6 +1123,8 @@ PODS: - React-Core - react-native-key-command (1.0.6): - React-Core + - react-native-keyboard-controller (1.10.4): + - React-Core - react-native-launch-arguments (4.0.2): - React - react-native-netinfo (11.2.1): @@ -1465,6 +1467,7 @@ DEPENDENCIES: - "react-native-image-manipulator (from `../node_modules/@oguzhnatly/react-native-image-manipulator`)" - react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-key-command (from `../node_modules/react-native-key-command`) + - react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`) - react-native-launch-arguments (from `../node_modules/react-native-launch-arguments`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-pager-view (from `../node_modules/react-native-pager-view`) @@ -1655,6 +1658,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-image-picker" react-native-key-command: :path: "../node_modules/react-native-key-command" + react-native-keyboard-controller: + :path: "../node_modules/react-native-keyboard-controller" react-native-launch-arguments: :path: "../node_modules/react-native-launch-arguments" react-native-netinfo: @@ -1850,6 +1855,7 @@ SPEC CHECKSUMS: react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 react-native-image-picker: 2381c008bbb09e72395a2d043c147b11bd1523d9 react-native-key-command: 5af6ee30ff4932f78da6a2109017549042932aa5 + react-native-keyboard-controller: 3a10a7aa3ecc4d2d892dace30d6b357804ffcaa9 react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: 8a7fd3f7130ef4ad2fb4276d5c9f8d3f28d2df3d react-native-pager-view: 02a5c4962530f7efc10dd51ee9cdabeff5e6c631 diff --git a/jest/setup.ts b/jest/setup.ts index 488e3e36a1d31..0d19ce78c093d 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -48,6 +48,9 @@ jest.mock('react-native-sound', () => { return SoundMock; }); +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')); + jest.mock('react-native-share', () => ({ default: jest.fn(), })); diff --git a/package-lock.json b/package-lock.json index bc373abcd9b0d..2b32be381810c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,6 +93,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b", "react-native-key-command": "^1.0.6", + "react-native-keyboard-controller": "^1.10.4", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", @@ -44347,6 +44348,16 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, + "node_modules/react-native-keyboard-controller": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.10.4.tgz", + "integrity": "sha512-PQ3AcKGnacDBeA1zB1y44XLgj0sZd3Py5Kpml412bKgYiM09JgoK7YbJcUxMayTeEGtZ8GTOteevGTbGq1Otrg==", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-reanimated": ">=2.3.0" + } + }, "node_modules/react-native-launch-arguments": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/react-native-launch-arguments/-/react-native-launch-arguments-4.0.2.tgz", diff --git a/package.json b/package.json index 1c4f23700bc47..526ec644b5a70 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b", "react-native-key-command": "^1.0.6", + "react-native-keyboard-controller": "^1.10.4", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", diff --git a/src/App.tsx b/src/App.tsx index 0e247d5faa534..9ee9415c04729 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; +import * as ActionSheetAwareScrollView from './components/ActionSheetAwareScrollView'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; import ActiveWorkspaceContextProvider from './components/ActiveWorkspace/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; @@ -13,6 +14,7 @@ import CustomStatusBarAndBackground from './components/CustomStatusBarAndBackgro import CustomStatusBarAndBackgroundContextProvider from './components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider'; import ErrorBoundary from './components/ErrorBoundary'; import HTMLEngineProvider from './components/HTMLEngineProvider'; +import KeyboardHandlerProvider from './components/KeyboardHandlerProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; @@ -75,6 +77,8 @@ function App({url}: AppProps) { CustomStatusBarAndBackgroundContextProvider, ActiveElementRoleProvider, ActiveWorkspaceContextProvider, + KeyboardHandlerProvider, + ActionSheetAwareScrollView.ActionSheetAwareScrollViewProvider, PlaybackContextProvider, VolumeContextProvider, VideoPopoverMenuContextProvider, diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx new file mode 100644 index 0000000000000..a8dda4adb621f --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -0,0 +1,209 @@ +import noop from 'lodash/noop'; +import PropTypes from 'prop-types'; +import type {PropsWithChildren} from 'react'; +import React, {createContext, useMemo} from 'react'; +import type {SharedValue} from 'react-native-reanimated'; +import type {ActionWithPayload, State} from '@hooks/useWorkletStateMachine'; +import useWorkletStateMachine from '@hooks/useWorkletStateMachine'; + +type MeasuredElements = { + fy?: number; + popoverHeight?: number; + height?: number; + composerHeight?: number; +}; +type Context = { + currentActionSheetState: SharedValue>; + transitionActionSheetState: (action: ActionWithPayload) => void; + transitionActionSheetStateWorklet: (action: ActionWithPayload) => void; + resetStateMachine: () => void; +}; +const defaultValue: Context = { + currentActionSheetState: { + value: { + previous: { + state: 'idle', + payload: null, + }, + current: { + state: 'idle', + payload: null, + }, + }, + addListener: noop, + removeListener: noop, + modify: noop, + }, + transitionActionSheetState: noop, + transitionActionSheetStateWorklet: noop, + resetStateMachine: noop, +}; + +const ActionSheetAwareScrollViewContext = createContext(defaultValue); + +const Actions = { + OPEN_KEYBOARD: 'KEYBOARD_OPEN', + CLOSE_KEYBOARD: 'CLOSE_KEYBOARD', + OPEN_POPOVER: 'OPEN_POPOVER', + CLOSE_POPOVER: 'CLOSE_POPOVER', + MEASURE_POPOVER: 'MEASURE_POPOVER', + MEASURE_COMPOSER: 'MEASURE_COMPOSER', + POPOVER_ANY_ACTION: 'POPOVER_ANY_ACTION', + OPEN_EMOJI_PICKER_POPOVER: 'OPEN_EMOJI_PICKER_POPOVER', + OPEN_EMOJI_PICKER_POPOVER_STANDALONE: 'OPEN_EMOJI_PICKER_POPOVER_STANDALONE', + CLOSE_EMOJI_PICKER_POPOVER: 'CLOSE_EMOJI_PICKER_POPOVER', + MEASURE_EMOJI_PICKER_POPOVER: 'MEASURE_EMOJI_PICKER_POPOVER', + HIDE_WITHOUT_ANIMATION: 'HIDE_WITHOUT_ANIMATION', + EDIT_REPORT: 'EDIT_REPORT', + SHOW_DELETE_CONFIRM_MODAL: 'SHOW_DELETE_CONFIRM_MODAL', + END_TRANSITION: 'END_TRANSITION', + OPEN_CALL_POPOVER: 'OPEN_CALL_POPOVER', + CLOSE_CONFIRM_MODAL: 'CLOSE_CONFIRM_MODAL', + MEASURE_CONFIRM_MODAL: 'MEASURE_CONFIRM_MODAL', + SHOW_ATTACHMENTS_POPOVER: 'SHOW_ATTACHMENTS_POPOVER', + CLOSE_ATTACHMENTS_POPOVER: 'CLOSE_ATTACHMENTS_POPOVER', + SHOW_ATTACHMENTS_PICKER_POPOVER: 'SHOW_ATTACHMENTS_PICKER_POPOVER', + CLOSE_EMOJI_PICKER_POPOVER_STANDALONE: 'CLOSE_EMOJI_PICKER_POPOVER_STANDALONE', + MEASURE_CALL_POPOVER: 'MEASURE_CALL_POPOVER', + CLOSE_CALL_POPOVER: 'CLOSE_CALL_POPOVER', +}; + +const States = { + IDLE: 'idle', + KEYBOARD_OPEN: 'keyboardOpen', + POPOVER_OPEN: 'popoverOpen', + POPOVER_CLOSED: 'popoverClosed', + KEYBOARD_POPOVER_CLOSED: 'keyboardPopoverClosed', + KEYBOARD_POPOVER_OPEN: 'keyboardPopoverOpen', + KEYBOARD_CLOSED_POPOVER: 'keyboardClosingPopover', + POPOVER_MEASURED: 'popoverMeasured', + EMOJI_PICKER_POPOVER_OPEN: 'emojiPickerPopoverOpen', + DELETE_MODAL_OPEN: 'deleteModalOpen', + DELETE_MODAL_WITH_KEYBOARD_OPEN: 'deleteModalWithKeyboardOpen', + EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN: 'emojiPickerPopoverWithKeyboardOpen', + EMOJI_PICKER_WITH_KEYBOARD_OPEN: 'emojiPickerWithKeyboardOpen', + CALL_POPOVER_WITH_KEYBOARD_OPEN: 'callPopoverWithKeyboardOpen', + CALL_POPOVER_WITH_KEYBOARD_CLOSED: 'callPopoverWithKeyboardClosed', + ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN: 'attachmentsPopoverWithKeyboardOpen', + ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED: 'attachmentsPopoverWithKeyboardClosed', + MODAL_DELETED: 'modalDeleted', + MODAL_WITH_KEYBOARD_OPEN_DELETED: 'modalWithKeyboardOpenDeleted', +}; + +const STATE_MACHINE = { + [States.IDLE]: { + [Actions.OPEN_POPOVER]: States.POPOVER_OPEN, + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + [Actions.MEASURE_POPOVER]: States.IDLE, + [Actions.MEASURE_COMPOSER]: States.IDLE, + [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, + [Actions.SHOW_ATTACHMENTS_PICKER_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, + }, + [States.POPOVER_OPEN]: { + [Actions.CLOSE_POPOVER]: States.POPOVER_CLOSED, + [Actions.MEASURE_POPOVER]: States.POPOVER_OPEN, + [Actions.MEASURE_COMPOSER]: States.POPOVER_OPEN, + [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, + [Actions.POPOVER_ANY_ACTION]: States.POPOVER_CLOSED, + [Actions.HIDE_WITHOUT_ANIMATION]: States.IDLE, + [Actions.EDIT_REPORT]: States.IDLE, + [Actions.SHOW_DELETE_CONFIRM_MODAL]: States.MODAL_DELETED, + }, + [States.POPOVER_CLOSED]: { + [Actions.END_TRANSITION]: States.IDLE, + }, + [States.EMOJI_PICKER_POPOVER_OPEN]: { + [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, + [Actions.CLOSE_EMOJI_PICKER_POPOVER]: States.POPOVER_CLOSED, + }, + [States.MODAL_DELETED]: { + [Actions.MEASURE_CONFIRM_MODAL]: States.MODAL_DELETED, + [Actions.CLOSE_CONFIRM_MODAL]: States.POPOVER_CLOSED, + }, + [States.KEYBOARD_OPEN]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + [Actions.OPEN_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.OPEN_EMOJI_PICKER_POPOVER_STANDALONE]: States.EMOJI_PICKER_WITH_KEYBOARD_OPEN, + [Actions.CLOSE_KEYBOARD]: States.IDLE, + [Actions.OPEN_CALL_POPOVER]: States.CALL_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.SHOW_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.SHOW_ATTACHMENTS_PICKER_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.MEASURE_COMPOSER]: States.KEYBOARD_OPEN, + }, + [States.KEYBOARD_POPOVER_OPEN]: { + [Actions.MEASURE_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.MEASURE_COMPOSER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.CLOSE_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, + [Actions.CLOSE_EMOJI_PICKER_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, + [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.SHOW_DELETE_CONFIRM_MODAL]: States.MODAL_WITH_KEYBOARD_OPEN_DELETED, + }, + [States.MODAL_WITH_KEYBOARD_OPEN_DELETED]: { + [Actions.MEASURE_CONFIRM_MODAL]: States.MODAL_WITH_KEYBOARD_OPEN_DELETED, + [Actions.CLOSE_CONFIRM_MODAL]: States.KEYBOARD_CLOSED_POPOVER, + }, + [States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN]: { + [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.CLOSE_EMOJI_PICKER_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, + }, + [States.EMOJI_PICKER_WITH_KEYBOARD_OPEN]: { + [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_WITH_KEYBOARD_OPEN, + [Actions.CLOSE_EMOJI_PICKER_POPOVER_STANDALONE]: States.KEYBOARD_POPOVER_CLOSED, + }, + [States.CALL_POPOVER_WITH_KEYBOARD_OPEN]: { + [Actions.MEASURE_POPOVER]: States.CALL_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.MEASURE_CALL_POPOVER]: States.CALL_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.CLOSE_CALL_POPOVER]: States.CALL_POPOVER_WITH_KEYBOARD_CLOSED, + }, + [States.CALL_POPOVER_WITH_KEYBOARD_CLOSED]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + }, + [States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN]: { + [Actions.MEASURE_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.MEASURE_COMPOSER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED, + }, + [States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + }, + [States.KEYBOARD_POPOVER_CLOSED]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + }, + [States.KEYBOARD_CLOSED_POPOVER]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + [Actions.END_TRANSITION]: States.KEYBOARD_OPEN, + }, +}; + +function ActionSheetAwareScrollViewProvider(props: PropsWithChildren) { + const {currentState, transition, transitionWorklet, reset} = useWorkletStateMachine(STATE_MACHINE, { + previous: { + state: 'idle', + payload: null, + }, + current: { + state: 'idle', + payload: null, + }, + }); + + const value = useMemo( + () => ({ + currentActionSheetState: currentState, + transitionActionSheetState: transition, + transitionActionSheetStateWorklet: transitionWorklet, + resetStateMachine: reset, + }), + [currentState, reset, transition, transitionWorklet], + ); + + return {props.children}; +} + +ActionSheetAwareScrollViewProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export {ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider, Actions, States}; diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx new file mode 100644 index 0000000000000..095cb25560779 --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -0,0 +1,301 @@ +import React, {useContext, useEffect, useRef} from 'react'; +import type {ViewProps} from 'react-native'; +import {useKeyboardHandler} from 'react-native-keyboard-controller'; +import Reanimated, {interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSequence, withSpring, withTiming} from 'react-native-reanimated'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {Actions, ActionSheetAwareScrollViewContext, States} from './ActionSheetAwareScrollViewContext'; + +const KeyboardState = { + UNKNOWN: 0, + OPENING: 1, + OPEN: 2, + CLOSING: 3, + CLOSED: 4, +}; +const useAnimatedKeyboard = () => { + const state = useSharedValue(KeyboardState.UNKNOWN); + const height = useSharedValue(0); + const progress = useSharedValue(0); + const heightWhenOpened = useSharedValue(0); + + useKeyboardHandler( + { + onStart: (e) => { + 'worklet'; + + // save the last keyboard height + if (e.height === 0) { + heightWhenOpened.value = height.value; + } + + if (e.height > 0) { + state.value = KeyboardState.OPENING; + } else { + state.value = KeyboardState.CLOSING; + } + }, + onMove: (e) => { + 'worklet'; + + progress.value = e.progress; + height.value = e.height; + }, + onEnd: (e) => { + 'worklet'; + + if (e.height > 0) { + state.value = KeyboardState.OPEN; + } else { + state.value = KeyboardState.CLOSED; + } + + height.value = e.height; + progress.value = e.progress; + }, + }, + [], + ); + + return {state, height, heightWhenOpened, progress}; +}; +const setInitialValueAndRunAnimation = (value: number, animation: number) => { + 'worklet'; + + return withSequence(withTiming(value, {duration: 0}), animation); +}; + +const useSafeAreaPaddings = () => { + const StyleUtils = useStyleUtils(); + const insets = useSafeAreaInsets(); + const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets ?? undefined); + + return {top: paddingTop, bottom: paddingBottom}; +}; + +const config = { + mass: 3, + stiffness: 1000, + damping: 500, +}; + +function ActionSheetKeyboardSpace(props: ViewProps) { + const styles = useThemeStyles(); + const safeArea = useSafeAreaPaddings(); + const keyboard = useAnimatedKeyboard(); + + // similar to using `global` in worklet but it's just a local object + const syncLocalWorkletState = useRef({ + lastState: KeyboardState.UNKNOWN, + }).current; + const {windowHeight} = useWindowDimensions(); + const {currentActionSheetState, transitionActionSheetStateWorklet: transition, transitionActionSheetState, resetStateMachine} = useContext(ActionSheetAwareScrollViewContext); + + // Reset state machine when component unmounts + useEffect(() => () => resetStateMachine(), [resetStateMachine]); + + useAnimatedReaction( + () => keyboard.state.value, + (lastState) => { + if (lastState === syncLocalWorkletState.lastState) { + return; + } + + syncLocalWorkletState.lastState = lastState; + + if (lastState === KeyboardState.OPEN) { + runOnJS(transitionActionSheetState)({ + type: Actions.OPEN_KEYBOARD, + }); + } else if (lastState === KeyboardState.CLOSED) { + runOnJS(transitionActionSheetState)({ + type: Actions.CLOSE_KEYBOARD, + }); + } + }, + [], + ); + + const translateY = useDerivedValue(() => { + const {current, previous} = currentActionSheetState.value; + + // we don't need to run any additional logic + // it will always return 0 for idle state + if (current.state === States.IDLE) { + return withSpring(0, config); + } + + const keyboardHeight = keyboard.height.value === 0 ? 0 : keyboard.height.value - safeArea.bottom; + // sometimes we need to know the last keyboard height + const lastKeyboardHeight = keyboard.heightWhenOpened.value - safeArea.bottom; + + const {popoverHeight = 0, fy, height, composerHeight = 0} = current.payload ?? {}; + + const invertedKeyboardHeight = keyboard.state.value === KeyboardState.CLOSED ? lastKeyboardHeight : 0; + + let elementOffset = 0; + + if (fy !== undefined && height !== undefined && popoverHeight !== undefined) { + elementOffset = fy + safeArea.top + height - (windowHeight - popoverHeight); + } + + // when the sate is not idle we know for sure we have previous state + const previousPayload = previous.payload ?? {}; + + let previousElementOffset = 0; + + if (previousPayload.fy !== undefined && previousPayload.height !== undefined && previousPayload.popoverHeight !== undefined) { + previousElementOffset = previousPayload.fy + safeArea.top + previousPayload.height - (windowHeight - previousPayload.popoverHeight); + } + + // Depending on the current and sometimes previous state we can return + // either animation or just a value + switch (current.state) { + case States.KEYBOARD_OPEN: { + if (previous.state === States.KEYBOARD_CLOSED_POPOVER) { + return Math.max(keyboard.heightWhenOpened.value - keyboard.height.value - safeArea.bottom, 0) + Math.max(elementOffset, 0); + } + + return withSpring(0, config); + } + + case States.POPOVER_CLOSED: { + return withSpring(0, config, () => { + transition({ + type: Actions.END_TRANSITION, + }); + }); + } + + case States.MODAL_DELETED: + case States.EMOJI_PICKER_POPOVER_OPEN: + case States.POPOVER_OPEN: { + if (popoverHeight) { + if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { + return withSpring(elementOffset < 0 ? 0 : elementOffset, config); + } + + return withSpring(Math.max(previousElementOffset, 0), config); + } + + return 0; + } + + case States.MODAL_WITH_KEYBOARD_OPEN_DELETED: + case States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN: { + // when item is higher than keyboard and bottom sheet + // we should just stay in place + if (elementOffset < 0) { + return invertedKeyboardHeight; + } + + const nextOffset = invertedKeyboardHeight + elementOffset; + if (previous?.payload?.popoverHeight !== popoverHeight) { + const previousOffset = invertedKeyboardHeight + previousElementOffset; + + if (previousElementOffset === 0 || nextOffset > previousOffset) { + return withSpring(nextOffset, config); + } + + return previousOffset; + } + + return nextOffset; + } + + case States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED: + case States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN: { + return interpolate(keyboard.progress.value, [0, 1], [popoverHeight - composerHeight, 0]); + } + case States.CALL_POPOVER_WITH_KEYBOARD_OPEN: { + if (keyboard.height.value > 0) { + return 0; + } + + return setInitialValueAndRunAnimation(lastKeyboardHeight, withSpring(popoverHeight - composerHeight, config)); + } + case States.CALL_POPOVER_WITH_KEYBOARD_CLOSED: { + // keyboard is opened + if (keyboard.height.value > 0) { + return 0; + } + + return withSpring(lastKeyboardHeight, config); + } + case States.EMOJI_PICKER_WITH_KEYBOARD_OPEN: { + if (keyboard.state.value === KeyboardState.CLOSED) { + return popoverHeight - composerHeight; + } + + return 0; + } + + case States.KEYBOARD_POPOVER_CLOSED: { + if (keyboard.heightWhenOpened.value === keyboard.height.value) { + return 0; + } + + return popoverHeight - composerHeight; + } + + case States.KEYBOARD_POPOVER_OPEN: { + if (keyboard.state.value === KeyboardState.OPEN) { + return 0; + } + + const nextOffset = elementOffset + lastKeyboardHeight; + + if (keyboard.state.value === KeyboardState.CLOSED && nextOffset > invertedKeyboardHeight) { + return withSpring(nextOffset < 0 ? 0 : nextOffset, config); + } + + if (elementOffset < 0) { + return lastKeyboardHeight - keyboardHeight; + } + + return lastKeyboardHeight; + } + + case States.KEYBOARD_CLOSED_POPOVER: { + if (elementOffset < 0) { + transition({type: Actions.END_TRANSITION}); + + return 0; + } + + if (keyboard.state.value === KeyboardState.CLOSED) { + return elementOffset + lastKeyboardHeight; + } + + if (keyboard.height.value > 0) { + return keyboard.heightWhenOpened.value - keyboard.height.value + elementOffset; + } + + return withTiming(elementOffset + lastKeyboardHeight, { + duration: 0, + }); + } + + default: + return 0; + } + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + paddingTop: translateY.value, + })); + + return ( + + ); +} + +ActionSheetKeyboardSpace.displayName = 'ReportKeyboardSpace'; + +export default ActionSheetKeyboardSpace; diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.ios.tsx new file mode 100644 index 0000000000000..2c40df7e61c66 --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/index.ios.tsx @@ -0,0 +1,31 @@ +import type {PropsWithChildren} from 'react'; +import React, {forwardRef} from 'react'; +import type {ScrollViewProps} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import {ScrollView} from 'react-native'; +import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; +import ActionSheetKeyboardSpace from './ActionSheetKeyboardSpace'; + +const ActionSheetAwareScrollView = forwardRef>((props, ref) => ( + + {props.children} + +)); + +export default ActionSheetAwareScrollView; + +/** + * This function should be used as renderScrollComponent prop for FlatList + * @param props - props that will be passed to the ScrollView from FlatList + * @returns - ActionSheetAwareScrollView + */ +function renderScrollComponent(props: ScrollViewProps) { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +export {renderScrollComponent, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider, Actions}; diff --git a/src/components/ActionSheetAwareScrollView/index.tsx b/src/components/ActionSheetAwareScrollView/index.tsx new file mode 100644 index 0000000000000..d22f991ce4cfa --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/index.tsx @@ -0,0 +1,31 @@ +// this whole file is just for other platforms +// iOS version has everything implemented +import type {PropsWithChildren} from 'react'; +import React, {forwardRef} from 'react'; +import type {ScrollViewProps} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import {ScrollView} from 'react-native'; +import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; + +const ActionSheetAwareScrollView = forwardRef>((props, ref) => ( + + {props.children} + +)); + +export default ActionSheetAwareScrollView; + +/** + * This is only used on iOS. On other platforms it's just undefined to be pass a prop to FlatList + * + * This function should be used as renderScrollComponent prop for FlatList + * @param {Object} props - props that will be passed to the ScrollView from FlatList + * @returns {React.ReactElement} - ActionSheetAwareScrollView + */ +const renderScrollComponent = undefined; + +export {renderScrollComponent, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider, Actions}; diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx index d389ac4b92f03..28d8150afd685 100644 --- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx +++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx @@ -40,7 +40,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow return ( - {({anchor, report, action, checkIfContextMenuActive}) => ( + {({onShowContextMenu, anchor, report, action, checkIfContextMenuActive}) => ( { @@ -52,8 +52,9 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow }} onPressIn={onPressIn} onPressOut={onPressOut} - // @ts-expect-error TODO: Remove this once ShowContextMenuContext (https://github.com/Expensify/App/issues/24980) is migrated to TypeScript. - onLongPress={(event) => showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onLongPress={(event) => + onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))) + } shouldUseHapticsOnLongPress accessibilityLabel={displayName} role={CONST.ROLE.BUTTON} diff --git a/src/components/AttachmentPicker/attachmentPickerPropTypes.js b/src/components/AttachmentPicker/attachmentPickerPropTypes.js index a3a346f5ea278..cfa74e6fd99d1 100644 --- a/src/components/AttachmentPicker/attachmentPickerPropTypes.js +++ b/src/components/AttachmentPicker/attachmentPickerPropTypes.js @@ -22,6 +22,9 @@ const propTypes = { * */ children: PropTypes.func.isRequired, + /** Optional callback attached to popover's children container */ + onLayout: PropTypes.func, + /** The types of files that can be selected with this picker. */ type: PropTypes.oneOf([CONST.ATTACHMENT_PICKER_TYPE.FILE, CONST.ATTACHMENT_PICKER_TYPE.IMAGE]), }; diff --git a/src/components/AttachmentPicker/index.native.js b/src/components/AttachmentPicker/index.native.js index 0387ee087127f..c39bb041538f8 100644 --- a/src/components/AttachmentPicker/index.native.js +++ b/src/components/AttachmentPicker/index.native.js @@ -112,7 +112,7 @@ const getDataForUpload = (fileData) => { * @param {propTypes} props * @returns {JSX.Element} */ -function AttachmentPicker({type, children, shouldHideCameraOption}) { +function AttachmentPicker({type, children, shouldHideCameraOption, onLayout}) { const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); @@ -333,6 +333,7 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) { return ( <> { close(); onCanceled.current(); diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index c0c146bbae08f..b7eb90e7a8368 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -1,6 +1,6 @@ import type {ReactNode} from 'react'; -import React from 'react'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import React, {useCallback, useContext} from 'react'; +import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -11,6 +11,7 @@ import colors from '@styles/theme/colors'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; +import {Actions, ActionSheetAwareScrollViewContext} from './ActionSheetAwareScrollView'; import Button from './Button'; import Header from './Header'; import Icon from './Icon'; @@ -93,12 +94,27 @@ function ConfirmContent({ iconAdditionalStyles, image, }: ConfirmContentProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const {translate} = useLocalize(); const theme = useTheme(); const {isOffline} = useNetwork(); const StyleUtils = useStyleUtils(); + const onLayout = useCallback( + (event: LayoutChangeEvent) => { + const {height} = event.nativeEvent.layout; + + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: Actions.MEASURE_CONFIRM_MODAL, + payload: { + popoverHeight: height, + }, + }); + }, + [actionSheetAwareScrollViewContext], + ); + const isCentered = shouldCenterContent; return ( @@ -115,7 +131,10 @@ function ConfirmContent({ )} - + {typeof iconSource === 'function' && ( diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index 438deb7e53d90..ade99f25aff84 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; -import React, {memo, useEffect, useRef} from 'react'; +import React, {memo, useContext, useEffect, useRef} from 'react'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -37,10 +38,48 @@ const defaultProps = { }; function EmojiPickerButton(props) { + const actionSheetContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const emojiPopoverAnchor = useRef(null); + const onPress = () => { + if (!props.isFocused) { + return; + } + + actionSheetContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.OPEN_EMOJI_PICKER_POPOVER_STANDALONE, + }); + + const onHide = () => { + actionSheetContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER_STANDALONE, + }); + + if (props.onModalHide) { + props.onModalHide(); + } + }; + + if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { + EmojiPickerAction.showEmojiPicker( + onHide, + props.onEmojiSelected, + emojiPopoverAnchor, + { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + shiftVertical: props.shiftVertical, + }, + () => {}, + props.emojiPickerID, + ); + } else { + EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); + } + }; + useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []); return ( @@ -49,27 +88,7 @@ function EmojiPickerButton(props) { ref={emojiPopoverAnchor} style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]} disabled={props.isDisabled} - onPress={() => { - if (!props.isFocused) { - return; - } - if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { - EmojiPickerAction.showEmojiPicker( - props.onModalHide, - props.onEmojiSelected, - emojiPopoverAnchor, - { - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - shiftVertical: props.shiftVertical, - }, - () => {}, - props.emojiPickerID, - ); - } else { - EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); - } - }} + onPress={onPress} id={props.id} accessibilityLabel={props.translate('reportActionCompose.emoji')} > @@ -87,4 +106,5 @@ function EmojiPickerButton(props) { EmojiPickerButton.propTypes = propTypes; EmojiPickerButton.defaultProps = defaultProps; EmojiPickerButton.displayName = 'EmojiPickerButton'; + export default compose(withLocalize, withNavigationFocus)(memo(EmojiPickerButton)); diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js index 67f0542fb82ca..c1c3597d57f64 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js @@ -1,7 +1,8 @@ -import React, {useCallback} from 'react'; +import React, {useCallback, useContext} from 'react'; import {View} from 'react-native'; import {runOnUI, scrollTo} from 'react-native-reanimated'; import _ from 'underscore'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -107,9 +108,25 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}) { }, [styles, windowWidth, preferredSkinTone, singleExecution, onEmojiSelected, translate, activeEmoji], ); + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); + const onLayout = useCallback( + (event) => { + const {height} = event.nativeEvent.layout; + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.MEASURE_EMOJI_PICKER_POPOVER, + payload: { + popoverHeight: height, + }, + }); + }, + [actionSheetAwareScrollViewContext], + ); return ( - + - {({anchor, report, action, checkIfContextMenuActive}) => ( + {({onShowContextMenu, anchor, report, action, checkIfContextMenuActive}) => ( { const route = ROUTES.REPORT_ATTACHMENTS.getRoute(report?.reportID ?? '', source); Navigation.navigate(route); }} - onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onLongPress={(event) => + onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))) + } shouldUseHapticsOnLongPress accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={translate('accessibilityHints.viewAttachment')} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 5d8c0f6ef81e0..b50838d4e2539 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -81,10 +81,12 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona return ( - {({anchor, report, action, checkIfContextMenuActive}) => ( + {({onShowContextMenu, anchor, report, action, checkIfContextMenuActive}) => ( showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onLongPress={(event) => + onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))) + } onPress={(event) => { event.preventDefault(); Navigation.navigate(navigationRoute); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index 39a1993c23346..69e9fa0e0f801 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -34,12 +34,14 @@ function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...d return ( - {({anchor, report, action, checkIfContextMenuActive}) => ( + {({onShowContextMenu, anchor, report, action, checkIfContextMenuActive}) => ( {})} onPressIn={onPressIn} onPressOut={onPressOut} - onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onLongPress={(event) => + onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))) + } shouldUseHapticsOnLongPress role={CONST.ROLE.PRESENTATION} accessibilityLabel={translate('accessibilityHints.prestyledText')} diff --git a/src/components/KeyboardAvoidingView/index.ios.tsx b/src/components/KeyboardAvoidingView/index.ios.tsx index a7cd767377ef3..485d230bbfd8b 100644 --- a/src/components/KeyboardAvoidingView/index.ios.tsx +++ b/src/components/KeyboardAvoidingView/index.ios.tsx @@ -2,7 +2,7 @@ * The KeyboardAvoidingView is only used on ios */ import React from 'react'; -import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native'; +import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native-keyboard-controller'; import type KeyboardAvoidingViewProps from './types'; function KeyboardAvoidingView(props: KeyboardAvoidingViewProps) { diff --git a/src/components/KeyboardHandlerProvider.tsx b/src/components/KeyboardHandlerProvider.tsx new file mode 100644 index 0000000000000..dc208d10aeb30 --- /dev/null +++ b/src/components/KeyboardHandlerProvider.tsx @@ -0,0 +1,12 @@ +import type {PropsWithChildren} from 'react'; +import React from 'react'; +import {Platform} from 'react-native'; +import {KeyboardProvider} from 'react-native-keyboard-controller'; + +type Props = PropsWithChildren; + +function KeyboardHandlerProvider({children}: Props) { + return {children}; +} + +export default KeyboardHandlerProvider; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index a391ff061baa5..5c7b12d95a433 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -1,6 +1,7 @@ import type {ImageContentFit} from 'expo-image'; import type {RefObject} from 'react'; import React, {useEffect, useRef, useState} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import type {ModalProps} from 'react-native-modal'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; @@ -60,6 +61,9 @@ type PopoverMenuProps = Partial & { /** Callback method fired when the user requests to close the modal */ onClose: () => void; + /** Optional callback passed to popover's children container */ + onLayout?: (e: LayoutChangeEvent) => void; + /** State that determines whether to display the modal or not */ isVisible: boolean; @@ -101,6 +105,7 @@ function PopoverMenu({ anchorPosition, anchorRef, onClose, + onLayout, headerText, fromSidebarMediumScreen, anchorAlignment = { @@ -214,7 +219,10 @@ function PopoverMenu({ withoutOverlay={withoutOverlay} shouldSetModalVisibility={shouldSetModalVisibility} > - + {!!headerText && {headerText}} {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} {currentMenuItems.map((item, menuIndex) => ( diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx index 792002441ac6e..ab78ab59d3a46 100644 --- a/src/components/PopoverWithMeasuredContent.tsx +++ b/src/components/PopoverWithMeasuredContent.tsx @@ -1,5 +1,5 @@ import isEqual from 'lodash/isEqual'; -import React, {useMemo, useState} from 'react'; +import React, {useContext, useMemo, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -7,6 +7,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import PopoverWithMeasuredContentUtils from '@libs/PopoverWithMeasuredContentUtils'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; +import * as ActionSheetAwareScrollView from './ActionSheetAwareScrollView'; import Popover from './Popover'; import type {PopoverProps} from './Popover/types'; import type {WindowDimensionsProps} from './withWindowDimensions/types'; @@ -44,6 +45,7 @@ function PopoverWithMeasuredContent({ hideModalContentWhileAnimating = false, ...props }: PopoverWithMeasuredContentProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const {windowWidth, windowHeight} = useWindowDimensions(); const [popoverWidth, setPopoverWidth] = useState(popoverDimensions.width); @@ -67,9 +69,22 @@ function PopoverWithMeasuredContent({ * Measure the size of the popover's content. */ const measurePopover = ({nativeEvent}: LayoutChangeEvent) => { - setPopoverWidth(nativeEvent.layout.width); - setPopoverHeight(nativeEvent.layout.height); + const {width, height} = nativeEvent.layout; + setPopoverWidth(width); + setPopoverHeight(height); setIsContentMeasured(true); + + // it handles the case when `measurePopover` is called with values like: 192, 192.00003051757812, 192 + // if we update it, then animation in `ActionSheetAwareScrollView` may be re-running + // and we'll see unsynchronized and junky animation + if (actionSheetAwareScrollViewContext.currentActionSheetState.value.current.payload?.popoverHeight !== Math.floor(popoverHeight)) { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.MEASURE_POPOVER, + payload: { + popoverHeight: Math.floor(popoverHeight), + }, + }); + } }; const adjustedAnchorPosition = useMemo(() => { diff --git a/src/components/Reactions/AddReactionBubble.tsx b/src/components/Reactions/AddReactionBubble.tsx index 6b7aa12b742ed..06b2b36e0b070 100644 --- a/src/components/Reactions/AddReactionBubble.tsx +++ b/src/components/Reactions/AddReactionBubble.tsx @@ -57,9 +57,10 @@ function AddReactionBubble({onSelectEmoji, reportAction, onPressOpenPicker, onWi useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []); const onPress = () => { - const openPicker = (refParam?: PickerRefElement, anchorOrigin?: AnchorOrigin) => { + const openPicker = (refParam?: PickerRefElement, anchorOrigin?: AnchorOrigin, onHide = () => {}) => { EmojiPickerAction.showEmojiPicker( () => { + onHide(); setIsEmojiPickerActive?.(false); }, (emojiCode, emojiObject) => { diff --git a/src/components/Reactions/QuickEmojiReactions/index.native.tsx b/src/components/Reactions/QuickEmojiReactions/index.native.tsx index b0eb88b31b68f..6c55beb9741d0 100644 --- a/src/components/Reactions/QuickEmojiReactions/index.native.tsx +++ b/src/components/Reactions/QuickEmojiReactions/index.native.tsx @@ -1,10 +1,17 @@ -import React from 'react'; +import React, {useContext} from 'react'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import BaseQuickEmojiReactions from './BaseQuickEmojiReactions'; -import type {OpenPickerCallback, QuickEmojiReactionsProps} from './types'; +import type {BaseQuickEmojiReactionsProps, OpenPickerCallback, QuickEmojiReactionsProps} from './types'; + +function QuickEmojiReactions({closeContextMenu, onEmojiSelected, ...rest}: QuickEmojiReactionsProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); -function QuickEmojiReactions({closeContextMenu, ...rest}: QuickEmojiReactionsProps) { const onPressOpenPicker = (openPicker?: OpenPickerCallback) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.OPEN_EMOJI_PICKER_POPOVER, + }); + // We first need to close the menu as it's a popover. // The picker is a popover as well and on mobile there can only // be one active popover at a time. @@ -13,13 +20,28 @@ function QuickEmojiReactions({closeContextMenu, ...rest}: QuickEmojiReactionsPro // gets closed, before the picker actually opens, we pass the composer // ref as anchor for the emoji picker popover. openPicker?.(ReportActionComposeFocusManager.composerRef); + + openPicker?.(ReportActionComposeFocusManager.composerRef, undefined, () => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER, + }); + }); }); }; + const onEmojiSelectedCallback: BaseQuickEmojiReactionsProps['onEmojiSelected'] = (emoji, emojiReactions) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER, + }); + + onEmojiSelected(emoji, emojiReactions); + }; + return ( ); diff --git a/src/components/Reactions/QuickEmojiReactions/types.ts b/src/components/Reactions/QuickEmojiReactions/types.ts index 7b6558364a559..e246da34189d1 100644 --- a/src/components/Reactions/QuickEmojiReactions/types.ts +++ b/src/components/Reactions/QuickEmojiReactions/types.ts @@ -7,7 +7,7 @@ import type {Locale, ReportAction, ReportActionReactions} from '@src/types/onyx' type PickerRefElement = RefObject; -type OpenPickerCallback = (element?: PickerRefElement, anchorOrigin?: AnchorOrigin) => void; +type OpenPickerCallback = (element?: PickerRefElement, anchorOrigin?: AnchorOrigin, callback?: () => void) => void; type CloseContextMenuCallback = () => void; diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.tsx b/src/components/Reactions/ReportActionItemEmojiReactions.tsx index c6bf4f9e40166..f932c55b97ce0 100644 --- a/src/components/Reactions/ReportActionItemEmojiReactions.tsx +++ b/src/components/Reactions/ReportActionItemEmojiReactions.tsx @@ -16,6 +16,7 @@ import type {Locale, ReportAction, ReportActionReactions} from '@src/types/onyx' import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import AddReactionBubble from './AddReactionBubble'; import EmojiReactionBubble from './EmojiReactionBubble'; +import type {OpenPickerCallback} from './QuickEmojiReactions/types'; import ReactionTooltipContent from './ReactionTooltipContent'; type ReportActionItemEmojiReactionsProps = WithCurrentUserPersonalDetailsProps & { @@ -35,6 +36,15 @@ type ReportActionItemEmojiReactionsProps = WithCurrentUserPersonalDetailsProps & */ toggleReaction: (emoji: Emoji) => void; + /** + * Function to call when the user presses on the add reaction button. + * This is only called when the user presses on the button, not on the + * reaction bubbles. + * This is optional, because we don't need it everywhere. + * For example in the ReportActionContextMenu we don't need it. + */ + onPressOpenPicker: (openPicker: OpenPickerCallback) => void; + /** We disable reacting with emojis on report actions that have errors */ shouldBlockReactions?: boolean; @@ -79,6 +89,7 @@ function ReportActionItemEmojiReactions({ reportAction, currentUserPersonalDetails, toggleReaction, + onPressOpenPicker, emojiReactions = {}, shouldBlockReactions = false, preferredLocale = CONST.LOCALES.DEFAULT, @@ -170,6 +181,7 @@ function ReportActionItemEmojiReactions({ })} {!shouldBlockReactions && ( void; + /** Callback for measuring child and running a defined callback/action later */ + onShowContextMenu?: (callback: () => void) => void; + /** Whether the IOU is hovered so we can modify its style */ isHovered?: boolean; @@ -68,6 +71,7 @@ function MoneyRequestAction({ reportID, isMostRecentIOUReportAction, contextMenuAnchor, + onShowContextMenu = () => {}, checkIfContextMenuActive = () => {}, chatReport, iouReport, @@ -118,6 +122,7 @@ function MoneyRequestAction({ isBillSplit={isSplitBillAction} action={action} contextMenuAnchor={contextMenuAnchor} + onShowContextMenu={onShowContextMenu} checkIfContextMenuActive={checkIfContextMenuActive} shouldShowPendingConversionMessage={shouldShowPendingConversionMessage} onPreviewPressed={onMoneyRequestPreviewPressed} diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 311ed75be3e01..1ebb71d7502b0 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -53,6 +53,7 @@ function MoneyRequestPreviewContent({ containerStyles, walletTerms, checkIfContextMenuActive = () => {}, + onShowContextMenu = () => {}, shouldShowPendingConversionMessage = false, isHovered = false, isWhisper = false, @@ -126,7 +127,7 @@ function MoneyRequestPreviewContent({ }; const showContextMenu = (event: GestureResponderEvent) => { - showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive); + onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive)); }; const getPreviewHeaderText = (): string => { diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts index 17dd42b2f7946..4a6dbbbd60fef 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts @@ -50,6 +50,9 @@ type MoneyRequestPreviewProps = MoneyRequestPreviewOnyxProps & { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive?: () => void; + /** Callback for measuring child and running a defined callback/action later */ + onShowContextMenu?: (callback: () => void) => void; + /** Extra styles to pass to View wrapper */ containerStyles?: StyleProp; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 743bfd8fff88c..a5583678389c9 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -72,6 +72,9 @@ type ReportPreviewProps = ReportPreviewOnyxProps & { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive?: () => void; + /** Callback for measuring child and running a defined callback/action later */ + onShowContextMenu: (callback: () => void) => void; + /** Whether a message is a whisper */ isWhisper?: boolean; @@ -94,6 +97,7 @@ function ReportPreview({ isHovered = false, isWhisper = false, checkIfContextMenuActive = () => {}, + onShowContextMenu = () => {}, }: ReportPreviewProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -230,7 +234,7 @@ function ReportPreview({ }} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} + onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive))} shouldUseHapticsOnLongPress style={[styles.flexRow, styles.justifyContentBetween, styles.reportPreviewBox]} role="button" diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index f0d9ad88868c5..664f33395103f 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -58,9 +58,22 @@ type TaskPreviewProps = WithCurrentUserPersonalDetailsProps & /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: () => void; + + /** Callback that will do measure of necessary layout elements and run provided callback */ + onShowContextMenu: (callback: () => void) => void; }; -function TaskPreview({taskReport, taskReportID, action, contextMenuAnchor, chatReportID, checkIfContextMenuActive, currentUserPersonalDetails, isHovered = false}: TaskPreviewProps) { +function TaskPreview({ + taskReport, + taskReportID, + action, + contextMenuAnchor, + chatReportID, + checkIfContextMenuActive, + currentUserPersonalDetails, + onShowContextMenu, + isHovered = false, +}: TaskPreviewProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -87,7 +100,7 @@ function TaskPreview({taskReport, taskReportID, action, contextMenuAnchor, chatR onPress={() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(taskReportID))} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} + onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive))} shouldUseHapticsOnLongPress style={[styles.flexRow, styles.justifyContentBetween]} role={CONST.ROLE.BUTTON} diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts index 374ca8a2f1e5d..330576b1c309b 100644 --- a/src/components/ShowContextMenuContext.ts +++ b/src/components/ShowContextMenuContext.ts @@ -14,10 +14,12 @@ type ShowContextMenuContextProps = { report: OnyxEntry; action: OnyxEntry; checkIfContextMenuActive: () => void; + onShowContextMenu: (callback: () => void) => void; }; const ShowContextMenuContext = createContext({ anchor: null, + onShowContextMenu: (callback) => callback(), report: null, action: null, checkIfContextMenuActive: () => {}, @@ -56,7 +58,7 @@ function showContextMenuForReport( action?.reportActionID, ReportUtils.getOriginalReportID(reportID, action), undefined, - checkIfContextMenuActive, + undefined, checkIfContextMenuActive, isArchivedRoom, ); diff --git a/src/components/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx index 464c72b8581fa..f5c36610a7b89 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -1,8 +1,9 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import {Actions, ActionSheetAwareScrollViewContext} from '@components/ActionSheetAwareScrollView'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import type {PopoverMenuItem} from '@components/PopoverMenu'; @@ -78,6 +79,7 @@ function ThreeDotsMenu({ disabled = false, modal = {}, }: ThreeDotsMenuProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollViewContext); const theme = useTheme(); const styles = useThemeStyles(); const [isPopupMenuVisible, setPopupMenuVisible] = useState(false); @@ -86,19 +88,25 @@ function ThreeDotsMenu({ const isBehindModal = modal?.willAlertModalBecomeVisible && !modal?.isPopover && !shouldOverlay; const showPopoverMenu = () => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: Actions.OPEN_CALL_POPOVER, + }); setPopupMenuVisible(true); }; - const hidePopoverMenu = () => { + const hidePopoverMenu = useCallback(() => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: Actions.CLOSE_CALL_POPOVER, + }); setPopupMenuVisible(false); - }; + }, [actionSheetAwareScrollViewContext]); useEffect(() => { if (!isBehindModal || !isPopupMenuVisible) { return; } hidePopoverMenu(); - }, [isBehindModal, isPopupMenuVisible]); + }, [hidePopoverMenu, isBehindModal, isPopupMenuVisible]); return ( <> diff --git a/src/hooks/useWorkletStateMachine.ts b/src/hooks/useWorkletStateMachine.ts new file mode 100644 index 0000000000000..5b09dc22b0597 --- /dev/null +++ b/src/hooks/useWorkletStateMachine.ts @@ -0,0 +1,170 @@ +import {useCallback} from 'react'; +import {runOnJS, runOnUI, useSharedValue} from 'react-native-reanimated'; +import Log from '@libs/Log'; + +// When you need to debug state machine change this to true +const DEBUG_MODE = false; + +type Payload = Record; +type ActionWithPayload

= { + type: string; + payload?: P; +}; +type StateHolder

= { + state: string; + payload: P | null; +}; +type State

= { + previous: StateHolder

; + current: StateHolder

; +}; + +type StateMachine = Record>; + +/** + * A hook that creates a state machine that can be used with Reanimated Worklets. + * You can transition state from worklet or from the JS thread. + * + * State machines are helpful for managing complex UI interactions. We want to transition + * between states based on user actions. But also we want to ignore some actions + * when we are in certain states. + * + * For example: + * 1. Initial state is idle. It can react to KEYBOARD_OPEN action. + * 2. We open emoji picker. It sends EMOJI_PICKER_OPEN action. + * 2. There is no handling for this action in idle state so we do nothing. + * 3. We close emoji picker and it sends EMOJI_PICKER_CLOSE action which again does nothing. + * 4. We open keyboard. It sends KEYBOARD_OPEN action. idle can react to this action + * by transitioning into keyboardOpen state + * 5. Our state is keyboardOpen. It can react to KEYBOARD_CLOSE, EMOJI_PICKER_OPEN actions + * 6. We open emoji picker again. It sends EMOJI_PICKER_OPEN action which transitions our state + * into emojiPickerOpen state. Now we react only to EMOJI_PICKER_CLOSE action. + * 7. Before rendering the emoji picker, the app hides the keyboard. + * It sends KEYBOARD_CLOSE action. But we ignore it since our emojiPickerOpen state can only handle + * EMOJI_PICKER_CLOSE action. so we write the logic for handling hiding the keyboard + * but maintaining the offset based on the keyboard state shared value + * 7. we close the picker and send EMOJI_PICKER_CLOSE action which transitions us back into keyboardOpen state. + * + * State machine object example: + * const stateMachine = { + * idle: { + * KEYBOARD_OPEN: 'keyboardOpen', + * }, + * keyboardOpen: { + * KEYBOARD_CLOSE: 'idle', + * EMOJI_PICKER_OPEN: 'emojiPickerOpen', + * }, + * emojiPickerOpen: { + * EMOJI_PICKER_CLOSE: 'keyboardOpen', + * }, + * } + * + * Initial state example: + * { + * previous: null, + * current: { + * state: 'idle', + * payload: null, + * }, + * } + * + * @param stateMachine - a state machine object + * @param initialState - the initial state of the state machine + * @returns an object containing the current state, a transition function, and a reset function + */ +function useWorkletStateMachine

(stateMachine: StateMachine, initialState: State

) { + const currentState = useSharedValue(initialState); + + const log = useCallback((message: string, params?: P | null) => { + 'worklet'; + + if (!DEBUG_MODE) { + return; + } + + // eslint-disable-next-line @typescript-eslint/unbound-method, @typescript-eslint/restrict-template-expressions + runOnJS(Log.client)(`[StateMachine] ${message}. Params: ${params}`); + }, []); + + const transitionWorklet = useCallback( + (action: ActionWithPayload

) => { + 'worklet'; + + if (!action) { + throw new Error('state machine action is required'); + } + + const state = currentState.value; + + log(`Current STATE: ${state.current.state}`); + log(`Next ACTION: ${action.type}`, action.payload); + + const nextMachine = stateMachine[state.current.state]; + + if (!nextMachine) { + log(`No next machine found for state: ${state.current.state}`); + return; + } + + const nextState = nextMachine[action.type]; + + if (!nextState) { + log(`No next state found for action: ${action.type}`); + return; + } + + let nextPayload; + + if (typeof action.payload === 'undefined') { + // we save previous payload + nextPayload = state.current.payload; + } else { + // we merge previous payload with the new payload + nextPayload = { + ...state.current.payload, + ...action.payload, + }; + } + + log(`Next STATE: ${nextState}`, nextPayload); + + currentState.value = { + previous: state.current, + current: { + state: nextState, + payload: nextPayload, + }, + }; + }, + [currentState, log, stateMachine], + ); + + const resetWorklet = useCallback(() => { + 'worklet'; + + log('RESET STATE MACHINE'); + currentState.value = initialState; + }, [currentState, initialState, log]); + + const reset = useCallback(() => { + runOnUI(resetWorklet)(); + }, [resetWorklet]); + + const transition = useCallback( + (action: ActionWithPayload

) => { + runOnUI(transitionWorklet)(action); + }, + [transitionWorklet], + ); + + return { + currentState, + transitionWorklet, + transition, + reset, + resetWorklet, + }; +} + +export type {ActionWithPayload, State}; +export default useWorkletStateMachine; diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 4a59485453450..0a7fcf7ce3cc4 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -1,11 +1,12 @@ import lodashIsEqual from 'lodash/isEqual'; import type {MutableRefObject, RefObject} from 'react'; -import React, {memo, useMemo, useRef, useState} from 'react'; +import React, {memo, useContext, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Text as RNText, View as ViewType} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import type {ContextMenuItemHandle} from '@components/ContextMenuItem'; import ContextMenuItem from '@components/ContextMenuItem'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; @@ -113,6 +114,7 @@ function BaseReportActionContextMenu({ disabledActions = [], setIsEmojiPickerActive, }: BaseReportActionContextMenuProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -233,6 +235,7 @@ function BaseReportActionContextMenu({ draftMessage, selection, close: () => setShouldKeepOpen(false), + transitionActionSheetState: actionSheetAwareScrollViewContext.transitionActionSheetState, openContextMenu: () => setShouldKeepOpen(true), interceptAnonymousUser, openOverflowMenu, diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index c5ab9bbff1f52..8db0a3da84131 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -5,6 +5,7 @@ import React from 'react'; import type {GestureResponderEvent, Text, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import * as Expensicons from '@components/Icon/Expensicons'; import MiniQuickEmojiReactions from '@components/Reactions/MiniQuickEmojiReactions'; import QuickEmojiReactions from '@components/Reactions/QuickEmojiReactions'; @@ -29,8 +30,8 @@ import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type {Beta, ReportAction, ReportActionReactions, Report as ReportType} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; +import {clearActiveReportAction, hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; import type {ContextMenuAnchor} from './ReportActionContextMenu'; -import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; /** Gets the HTML version of the message in an action */ function getActionHtml(reportAction: OnyxEntry): string { @@ -69,6 +70,7 @@ type ContextMenuActionPayload = { draftMessage: string; selection: string; close: () => void; + transitionActionSheetState: (params: {type: string; payload?: Record}) => void; openContextMenu: () => void; interceptAnonymousUser: (callback: () => void, isAnonymousAction?: boolean) => void; anchor?: MutableRefObject; @@ -215,7 +217,7 @@ const ContextMenuActions: ContextMenuAction[] = [ icon: Expensicons.Pencil, shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && ReportUtils.canEditReportAction(reportAction) && !isArchivedRoom && !isChronosReport, - onPress: (closePopover, {reportID, reportAction, draftMessage}) => { + onPress: (closePopover, {reportID, reportAction, draftMessage, transitionActionSheetState}) => { if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { hideContextMenu(false); const childReportID = reportAction?.childReportID ?? '0'; @@ -232,6 +234,10 @@ const ContextMenuActions: ContextMenuAction[] = [ }; if (closePopover) { + transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.EDIT_REPORT, + }); + // Hide popover, then call editAction hideContextMenu(false, editAction); return; @@ -426,10 +432,21 @@ const ContextMenuActions: ContextMenuAction[] = [ !isArchivedRoom && !isChronosReport && !ReportActionsUtils.isMessageDeleted(reportAction), - onPress: (closePopover, {reportID, reportAction}) => { + onPress: (closePopover, {reportID, reportAction, transitionActionSheetState}) => { if (closePopover) { + transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.SHOW_DELETE_CONFIRM_MODAL, + }); + + const onClose = () => { + transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_CONFIRM_MODAL, + }); + clearActiveReportAction(); + }; + // Hide popover, then call showDeleteConfirmModal - hideContextMenu(false, () => showDeleteModal(reportID, reportAction)); + hideContextMenu(false, () => showDeleteModal(reportID, reportAction, true, onClose, onClose)); return; } diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 68c7f08836835..aae464cc11118 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -1,9 +1,11 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import type {FileObject} from '@components/AttachmentModal'; import AttachmentPicker from '@components/AttachmentPicker'; import Icon from '@components/Icon'; @@ -110,11 +112,12 @@ function AttachmentPickerWithMenuItems({ actionButtonRef, raiseIsScrollLikelyLayoutTriggered, }: AttachmentPickerWithMenuItemsProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const isFocused = useIsFocused(); const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const {windowHeight} = useWindowDimensions(); + const {windowWidth} = useWindowDimensions(); /** * Returns the list of IOU Options @@ -160,6 +163,18 @@ function AttachmentPickerWithMenuItems({ ]; }, [report, reportID, translate]); + const measurePopover = useCallback( + ({nativeEvent}: LayoutChangeEvent) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.MEASURE_POPOVER, + payload: { + popoverHeight: nativeEvent.layout.height, + }, + }); + }, + [actionSheetAwareScrollViewContext], + ); + const onPopoverMenuClose = () => { setMenuVisibility(false); onMenuClosed(); @@ -282,6 +297,7 @@ function AttachmentPickerWithMenuItems({ { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: isMenuVisible ? ActionSheetAwareScrollView.Actions.SHOW_ATTACHMENTS_POPOVER : ActionSheetAwareScrollView.Actions.CLOSE_ATTACHMENTS_POPOVER, + }); + }, [actionSheetAwareScrollViewContext, isMenuVisible]); + const reportRecipientAcountIDs = ReportUtils.getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); const reportRecipient = personalDetails[reportRecipientAcountIDs[0]]; const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused; @@ -351,6 +359,18 @@ function ReportActionCompose({ runOnJS(submitForm)(); }, [isSendDisabled, resetFullComposerSize, submitForm, animatedRef, isReportReadyForDisplay]); + const measureComposer = useCallback( + (e: LayoutChangeEvent) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.MEASURE_COMPOSER, + payload: { + composerHeight: e.nativeEvent.layout.height, + }, + }); + }, + [actionSheetAwareScrollViewContext], + ); + const emojiShiftVertical = useMemo(() => { const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; @@ -363,7 +383,10 @@ function ReportActionCompose({ {shouldShowReportRecipientLocalTime && hasReportRecipient && } - + { setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(action.reportActionID)); - }, [action.reportActionID]); + + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_POPOVER, + }); + }, [actionSheetAwareScrollViewContext, action.reportActionID]); + + const handlePressOpenPicker = useCallback( + (openPicker: OpenPickerCallback) => { + if (!(popoverAnchorRef.current && 'measureInWindow' in popoverAnchorRef.current)) { + return; + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + popoverAnchorRef.current.measureInWindow((_fx, fy, _width, height) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.OPEN_EMOJI_PICKER_POPOVER, + payload: { + fy, + height, + }, + }); + + openPicker(undefined, undefined, () => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER, + }); + }); + }); + }, + [actionSheetAwareScrollViewContext], + ); + + const handleShowContextMenu = useCallback( + (callback: () => void) => { + if (!(popoverAnchorRef.current && 'measureInWindow' in popoverAnchorRef.current)) { + return; + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + popoverAnchorRef.current?.measureInWindow((_fx, fy, _width, height) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.OPEN_POPOVER, + payload: { + popoverHeight: 0, + fy, + height, + }, + }); + + callback(); + }); + }, + [actionSheetAwareScrollViewContext], + ); /** * Show the ReportActionContextMenu modal popover. @@ -308,29 +364,31 @@ function ReportActionItem({ return; } - setIsContextMenuActive(true); - const selection = SelectionScraper.getCurrentSelection(); - ReportActionContextMenu.showContextMenu( - CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - selection, - popoverAnchorRef.current, - report.reportID, - action.reportActionID, - originalReportID, - draftMessage ?? '', - () => setIsContextMenuActive(true), - toggleContextMenuFromActiveReportAction, - ReportUtils.isArchivedRoom(originalReport), - ReportUtils.chatIncludesChronos(originalReport), - false, - false, - [], - false, - setIsEmojiPickerActive as () => void, - ); + handleShowContextMenu(() => { + setIsContextMenuActive(true); + const selection = SelectionScraper.getCurrentSelection(); + ReportActionContextMenu.showContextMenu( + CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + event, + selection, + popoverAnchorRef.current, + report.reportID, + action.reportActionID, + originalReportID, + draftMessage ?? '', + () => setIsContextMenuActive(true), + toggleContextMenuFromActiveReportAction, + ReportUtils.isArchivedRoom(originalReport), + ReportUtils.chatIncludesChronos(originalReport), + false, + false, + [], + false, + setIsEmojiPickerActive as () => void, + ); + }); }, - [draftMessage, action, report.reportID, toggleContextMenuFromActiveReportAction, originalReport, originalReportID], + [draftMessage, action, report.reportID, toggleContextMenuFromActiveReportAction, originalReport, originalReportID, handleShowContextMenu], ); // Handles manual scrolling to the bottom of the chat when the last message is an actionable mention whisper and it's resolved. @@ -358,8 +416,9 @@ function ReportActionItem({ report, action, checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, + onShowContextMenu: handleShowContextMenu, }), - [report, action, toggleContextMenuFromActiveReportAction], + [report, action, toggleContextMenuFromActiveReportAction, handleShowContextMenu], ); const actionableItemButtons: ActionableItem[] = useMemo(() => { @@ -429,6 +488,7 @@ function ReportActionItem({ isMostRecentIOUReportAction={isMostRecentIOUReportAction} isHovered={hovered} contextMenuAnchor={popoverAnchorRef.current} + onShowContextMenu={handleShowContextMenu} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} style={displayAsGroup ? [] : [styles.mt2]} isWhisper={isWhisper} @@ -445,6 +505,7 @@ function ReportActionItem({ containerStyles={displayAsGroup ? [] : [styles.mt2]} action={action} isHovered={hovered} + onShowContextMenu={handleShowContextMenu} contextMenuAnchor={popoverAnchorRef.current} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} isWhisper={isWhisper} @@ -460,6 +521,7 @@ function ReportActionItem({ chatReportID={report.reportID} action={action} isHovered={hovered} + onShowContextMenu={handleShowContextMenu} contextMenuAnchor={popoverAnchorRef.current} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} policyID={report.policyID ?? ''} @@ -592,6 +654,7 @@ function ReportActionItem({ {!ReportActionsUtils.isMessageDeleted(action) && (