diff --git a/jest/setup.ts b/jest/setup.ts index 1adc7bc86979..574846b152b7 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -78,6 +78,8 @@ jest.mock('react-native-reanimated', () => ({ ...jest.requireActual('react-native-reanimated/mock'), createAnimatedPropAdapter: jest.fn, useReducedMotion: jest.fn, + useScrollViewOffset: jest.fn(() => 0), + useAnimatedRef: jest.fn(() => jest.fn()), LayoutAnimationConfig: jest.fn, })); @@ -164,3 +166,9 @@ jest.mock('@libs/prepareRequestPayload/index.native.ts', () => ({ return Promise.resolve(formData); }), })); + +jest.mock('@src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: jest.fn(() => jest.fn()), // Return a function that returns a function +})); diff --git a/src/App.tsx b/src/App.tsx index 789cac513431..e7dd3684ef39 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 {ActionSheetAwareScrollViewProvider} from './components/ActionSheetAwareScrollView'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; import ComposeProviders from './components/ComposeProviders'; @@ -102,6 +103,7 @@ function App({url, hybridAppSettings, timestamp}: AppProps) { EnvironmentProvider, CustomStatusBarAndBackgroundContextProvider, ActiveElementRoleProvider, + ActionSheetAwareScrollViewProvider, PlaybackContextProvider, FullScreenContextProvider, VolumeContextProvider, diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx new file mode 100644 index 000000000000..cd5b20bbf10e --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -0,0 +1,141 @@ +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 {ValueOf} from 'type-fest'; +import type {ActionWithPayload, State, StateMachine} from '@hooks/useWorkletStateMachine'; +import useWorkletStateMachine from '@hooks/useWorkletStateMachine'; + +type MeasuredElements = { + frameY?: number; + popoverHeight?: number; + height?: number; + composerHeight?: number; +}; + +type Context = { + currentActionSheetState: SharedValue>; + transitionActionSheetState: (action: ActionWithPayload) => void; + transitionActionSheetStateWorklet: (action: ActionWithPayload) => void; + resetStateMachine: () => void; +}; + +/** Holds all information that is needed to coordinate the state value for the action sheet state machine. */ +const currentActionSheetStateValue = { + previous: { + state: 'idle', + payload: null, + }, + current: { + state: 'idle', + payload: null, + }, +}; +const defaultValue: Context = { + currentActionSheetState: { + value: currentActionSheetStateValue, + addListener: noop, + removeListener: noop, + modify: noop, + get: () => currentActionSheetStateValue, + set: 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', + HIDE_WITHOUT_ANIMATION: 'HIDE_WITHOUT_ANIMATION', + END_TRANSITION: 'END_TRANSITION', +} as const; + +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', + MODAL_WITH_KEYBOARD_OPEN_DELETED: 'modalWithKeyboardOpenDeleted', +} as const; + +const STATE_MACHINE: StateMachine, ValueOf> = { + [States.IDLE]: { + [Actions.OPEN_POPOVER]: States.POPOVER_OPEN, + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + [Actions.MEASURE_POPOVER]: States.IDLE, + [Actions.MEASURE_COMPOSER]: States.IDLE, + }, + [States.POPOVER_OPEN]: { + [Actions.CLOSE_POPOVER]: States.POPOVER_CLOSED, + [Actions.MEASURE_POPOVER]: States.POPOVER_OPEN, + [Actions.MEASURE_COMPOSER]: States.POPOVER_OPEN, + [Actions.POPOVER_ANY_ACTION]: States.POPOVER_CLOSED, + [Actions.HIDE_WITHOUT_ANIMATION]: States.IDLE, + }, + [States.POPOVER_CLOSED]: { + [Actions.END_TRANSITION]: States.IDLE, + }, + [States.KEYBOARD_OPEN]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + [Actions.OPEN_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.CLOSE_KEYBOARD]: States.IDLE, + [Actions.MEASURE_COMPOSER]: States.KEYBOARD_OPEN, + }, + [States.KEYBOARD_POPOVER_OPEN]: { + [Actions.MEASURE_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.CLOSE_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, + [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 000000000000..cf96a0823b0f --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -0,0 +1,267 @@ +import React, {useContext, useEffect} from 'react'; +import type {ViewProps} from 'react-native'; +import {useKeyboardHandler} from 'react-native-keyboard-controller'; +import Reanimated, {useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSequence, withSpring, withTiming} from 'react-native-reanimated'; +import type {SharedValue} from 'react-native-reanimated'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +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 SPRING_CONFIG = { + mass: 3, + stiffness: 1000, + damping: 500, +}; + +const useAnimatedKeyboard = () => { + const state = useSharedValue(KeyboardState.UNKNOWN); + const height = useSharedValue(0); + const lastHeight = useSharedValue(0); + const heightWhenOpened = useSharedValue(0); + + useKeyboardHandler( + { + onStart: (e) => { + 'worklet'; + + // Save the last keyboard height + if (e.height !== 0) { + heightWhenOpened.set(e.height); + height.set(0); + } + height.set(heightWhenOpened.get()); + lastHeight.set(e.height); + state.set(e.height > 0 ? KeyboardState.OPENING : KeyboardState.CLOSING); + }, + onMove: (e) => { + 'worklet'; + + height.set(e.height); + }, + onEnd: (e) => { + 'worklet'; + + state.set(e.height > 0 ? KeyboardState.OPEN : KeyboardState.CLOSED); + height.set(e.height); + }, + }, + [], + ); + + return {state, height, heightWhenOpened}; +}; + +type ActionSheetKeyboardSpaceProps = ViewProps & { + /** scroll offset of the parent ScrollView */ + position?: SharedValue; +}; + +function ActionSheetKeyboardSpace(props: ActionSheetKeyboardSpaceProps) { + const styles = useThemeStyles(); + const { + unmodifiedPaddings: {top: paddingTop = 0, bottom: paddingBottom = 0}, + } = useSafeAreaPaddings(); + const keyboard = useAnimatedKeyboard(); + const {position} = props; + + // Similar to using `global` in worklet but it's just a local object + const syncLocalWorkletState = useSharedValue(KeyboardState.UNKNOWN); + const {windowHeight} = useWindowDimensions(); + const {currentActionSheetState, transitionActionSheetStateWorklet: transition, resetStateMachine} = useContext(ActionSheetAwareScrollViewContext); + + // Reset state machine when component unmounts + // eslint-disable-next-line arrow-body-style + useEffect(() => { + return () => resetStateMachine(); + }, [resetStateMachine]); + + useAnimatedReaction( + () => keyboard.state.get(), + (lastState) => { + if (lastState === syncLocalWorkletState.get()) { + return; + } + // eslint-disable-next-line react-compiler/react-compiler + syncLocalWorkletState.set(lastState); + + if (lastState === KeyboardState.OPEN) { + transition({type: Actions.OPEN_KEYBOARD}); + } else if (lastState === KeyboardState.CLOSED) { + transition({type: Actions.CLOSE_KEYBOARD}); + } + }, + [], + ); + + const translateY = useDerivedValue(() => { + const {current, previous} = currentActionSheetState.get(); + + // 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, SPRING_CONFIG); + } + + const keyboardHeight = keyboard.height.get() === 0 ? 0 : keyboard.height.get() - paddingBottom; + + // Sometimes we need to know the last keyboard height + const lastKeyboardHeight = keyboard.heightWhenOpened.get() - paddingBottom; + const {popoverHeight = 0, frameY, height} = current.payload ?? {}; + const invertedKeyboardHeight = keyboard.state.get() === KeyboardState.CLOSED ? lastKeyboardHeight : 0; + const elementOffset = frameY !== undefined && height !== undefined && popoverHeight !== undefined ? frameY + paddingTop + height - (windowHeight - popoverHeight) : 0; + + // when the state is not idle we know for sure we have the previous state + const previousPayload = previous.payload ?? {}; + const previousElementOffset = + previousPayload.frameY !== undefined && previousPayload.height !== undefined && previousPayload.popoverHeight !== undefined + ? previousPayload.frameY + paddingTop + previousPayload.height - (windowHeight - previousPayload.popoverHeight) + : 0; + + const isOpeningKeyboard = syncLocalWorkletState.get() === 1; + const isClosingKeyboard = syncLocalWorkletState.get() === 3; + const isClosedKeyboard = syncLocalWorkletState.get() === 4; + + // 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 (isClosedKeyboard || isOpeningKeyboard) { + return lastKeyboardHeight - keyboardHeight; + } + if (previous.state === States.KEYBOARD_CLOSED_POPOVER || (previous.state === States.KEYBOARD_OPEN && elementOffset < 0)) { + const returnValue = Math.max(keyboard.heightWhenOpened.get() - keyboard.height.get() - paddingBottom, 0) + Math.max(elementOffset, 0); + return returnValue; + } + return withSpring(0, SPRING_CONFIG); + } + + case States.POPOVER_CLOSED: { + return withSpring(0, SPRING_CONFIG, () => { + transition({ + type: Actions.END_TRANSITION, + }); + }); + } + + case States.POPOVER_OPEN: { + if (popoverHeight) { + if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { + const returnValue = elementOffset < 0 ? 0 : elementOffset; + return withSpring(returnValue, SPRING_CONFIG); + } + + const returnValue = Math.max(previousElementOffset, 0); + return withSpring(returnValue, SPRING_CONFIG); + } + + return 0; + } + + case States.KEYBOARD_POPOVER_OPEN: { + if (keyboard.state.get() === KeyboardState.OPEN) { + return withSpring(0, SPRING_CONFIG); + } + + const nextOffset = elementOffset + lastKeyboardHeight; + const scrollOffset = position?.get() ?? 0; + + // Check if there's a space not filled by content and we need to move + const hasWhiteGap = + popoverHeight && + // Content would go too far up (beyond popover bounds) + (nextOffset < -popoverHeight || + // Or content would go below top of screen (only if not significantly scrolled) + (nextOffset > 0 && popoverHeight < lastKeyboardHeight && scrollOffset < popoverHeight) || + // Or content would create a gap by being positioned above minimum allowed position + (popoverHeight < lastKeyboardHeight && nextOffset > -popoverHeight && scrollOffset < popoverHeight) || + // Or there's a significant gap considering scroll position + (popoverHeight < lastKeyboardHeight && + scrollOffset > 0 && + scrollOffset < popoverHeight && + // When scrolled, check if the gap between content and keyboard would be too large + (nextOffset + scrollOffset > popoverHeight / 2 || + // Or if content would be pushed too far down relative to scroll + elementOffset + scrollOffset > -popoverHeight / 2))); + + if (keyboard.state.get() === KeyboardState.CLOSED) { + if (hasWhiteGap) { + return withSpring(nextOffset, SPRING_CONFIG); + } + + if (nextOffset > invertedKeyboardHeight) { + return withSpring(nextOffset < 0 ? 0 : nextOffset, SPRING_CONFIG); + } + } + + if (elementOffset < 0) { + const heightDifference = (frameY ?? 0) - keyboardHeight - paddingTop - paddingBottom; + if (isClosingKeyboard) { + if (hasWhiteGap) { + const targetOffset = Math.max(heightDifference - (scrollOffset > 0 ? scrollOffset / 2 : 0), -popoverHeight); + return withSequence(withTiming(keyboardHeight, {duration: 0}), withSpring(targetOffset, SPRING_CONFIG)); + } + + return withSpring(Math.max(elementOffset + lastKeyboardHeight, -popoverHeight), SPRING_CONFIG); + } + + if (hasWhiteGap && heightDifference > paddingTop) { + return withSequence(withTiming(lastKeyboardHeight - keyboardHeight, {duration: 0}), withSpring(Math.max(heightDifference, -popoverHeight), SPRING_CONFIG)); + } + + return lastKeyboardHeight - keyboardHeight; + } + + return lastKeyboardHeight; + } + + case States.KEYBOARD_CLOSED_POPOVER: { + if (elementOffset < 0) { + transition({type: Actions.END_TRANSITION}); + + return 0; + } + + if (keyboard.state.get() === KeyboardState.CLOSED) { + const returnValue = elementOffset + lastKeyboardHeight; + return returnValue; + } + + if (keyboard.height.get() > 0) { + const returnValue = keyboard.heightWhenOpened.get() - keyboard.height.get() + elementOffset; + return returnValue; + } + + return withTiming(elementOffset + lastKeyboardHeight, { + duration: 0, + }); + } + + default: + return 0; + } + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + paddingTop: translateY.get(), + })); + + return ( + + ); +} + +ActionSheetKeyboardSpace.displayName = 'ActionSheetKeyboardSpace'; + +export default ActionSheetKeyboardSpace; diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.ios.tsx new file mode 100644 index 000000000000..9c465b966daf --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/index.ios.tsx @@ -0,0 +1,50 @@ +import type {PropsWithChildren} from 'react'; +import React, {forwardRef, useCallback} from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {ScrollView, ScrollViewProps} from 'react-native'; +import Reanimated, {useAnimatedRef, useScrollViewOffset} from 'react-native-reanimated'; +import {Actions, ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider} from './ActionSheetAwareScrollViewContext'; +import ActionSheetKeyboardSpace from './ActionSheetKeyboardSpace'; + +const ActionSheetAwareScrollView = forwardRef>((props, ref) => { + const scrollViewAnimatedRef = useAnimatedRef(); + const position = useScrollViewOffset(scrollViewAnimatedRef); + + const onRef = useCallback( + (assignedRef: Reanimated.ScrollView) => { + if (typeof ref === 'function') { + ref(assignedRef); + } else if (ref) { + // eslint-disable-next-line no-param-reassign + ref.current = assignedRef; + } + + scrollViewAnimatedRef(assignedRef); + }, + [ref, scrollViewAnimatedRef], + ); + + return ( + + {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 000000000000..d22f991ce4cf --- /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/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index 26d1a902b475..776ab9188a14 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -1,15 +1,17 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {memo, useEffect, useRef} from 'react'; -import type {GestureResponderEvent} from 'react-native'; +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 type PressableProps from '@components/Pressable/GenericPressable/types'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import getButtonState from '@libs/getButtonState'; -import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; +import {emojiPickerRef, resetEmojiPopoverAnchor, showEmojiPicker} from '@userActions/EmojiPickerAction'; +import type {OnEmojiSelected, OnModalHideValue} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; type EmojiPickerButtonProps = { @@ -20,24 +22,53 @@ type EmojiPickerButtonProps = { emojiPickerID?: string; /** A callback function when the button is pressed */ - onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void; + onPress?: PressableProps['onPress']; /** Emoji popup anchor offset shift vertical */ shiftVertical?: number; - onModalHide: EmojiPickerAction.OnModalHideValue; + onModalHide: OnModalHideValue; - onEmojiSelected: EmojiPickerAction.OnEmojiSelected; + onEmojiSelected: OnEmojiSelected; }; function EmojiPickerButton({isDisabled = false, emojiPickerID = '', shiftVertical = 0, onPress, onModalHide, onEmojiSelected}: EmojiPickerButtonProps) { + const actionSheetContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const emojiPopoverAnchor = useRef(null); const {translate} = useLocalize(); const isFocused = useIsFocused(); - useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []); + const openEmojiPicker: PressableProps['onPress'] = (e) => { + if (!isFocused) { + return; + } + + actionSheetContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_KEYBOARD, + }); + + if (!emojiPickerRef?.current?.isEmojiPickerVisible) { + showEmojiPicker( + onModalHide, + onEmojiSelected, + emojiPopoverAnchor, + { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + shiftVertical, + }, + () => {}, + emojiPickerID, + ); + } else { + emojiPickerRef.current.hideEmojiPicker(); + } + onPress?.(e); + }; + + useEffect(() => resetEmojiPopoverAnchor, []); return ( @@ -45,28 +76,7 @@ function EmojiPickerButton({isDisabled = false, emojiPickerID = '', shiftVertica ref={emojiPopoverAnchor} style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]} disabled={isDisabled} - onPress={(e) => { - if (!isFocused) { - return; - } - if (!EmojiPickerAction.emojiPickerRef?.current?.isEmojiPickerVisible) { - EmojiPickerAction.showEmojiPicker( - onModalHide, - onEmojiSelected, - emojiPopoverAnchor, - { - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - shiftVertical, - }, - () => {}, - emojiPickerID, - ); - } else { - EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); - } - onPress?.(e); - }} + onPress={openEmojiPicker} id={CONST.EMOJI_PICKER_BUTTON_NATIVE_ID} accessibilityLabel={translate('reportActionCompose.emoji')} > diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 431372dedbef..e650df1be650 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -101,7 +101,7 @@ function ImageRenderer({tnode}: ImageRendererProps) { thumbnailImageComponent ) : ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled, shouldDisplayContextMenu}) => ( + {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled, shouldDisplayContextMenu}) => ( {({reportID, accountID, type}) => ( + showContextMenuForReport(event, anchor, report?.reportID, action, checkIfContextMenuActive, isArchivedNonExpenseReport(report, reportNameValuePairs)), + ); }} isNested shouldUseHapticsOnLongPress diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 3f15e286044b..71e711a93dcb 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -31,7 +31,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const htmlAttribAccountID = tnode.attributes.accountid; - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true}); const htmlAttributeAccountID = tnode.attributes.accountid; let accountID: number; @@ -70,14 +70,16 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona return ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled, shouldDisplayContextMenu}) => ( + {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled, shouldDisplayContextMenu}) => ( { if (isDisabled || !shouldDisplayContextMenu) { return; } - showContextMenuForReport(event, anchor, report?.reportID, action, checkIfContextMenuActive, isArchivedNonExpenseReport(report, reportNameValuePairs)); + return onShowContextMenu(() => + showContextMenuForReport(event, anchor, report?.reportID, action, checkIfContextMenuActive, isArchivedNonExpenseReport(report, reportNameValuePairs)), + ); }} onPress={(event) => { event.preventDefault(); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index 08b2868a9b88..88b614365012 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -52,16 +52,18 @@ function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...d return ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled, shouldDisplayContextMenu}) => ( + {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled, shouldDisplayContextMenu}) => ( {})} onPressIn={onPressIn} onPressOut={onPressOut} onLongPress={(event) => { - if (isDisabled || !shouldDisplayContextMenu) { - return; - } - showContextMenuForReport(event, anchor, report?.reportID, action, checkIfContextMenuActive, isArchivedNonExpenseReport(report, reportNameValuePairs)); + onShowContextMenu(() => { + if (isDisabled || !shouldDisplayContextMenu) { + return; + } + return showContextMenuForReport(event, anchor, report?.reportID, action, checkIfContextMenuActive, isArchivedNonExpenseReport(report, reportNameValuePairs)); + }); }} shouldUseHapticsOnLongPress role={CONST.ROLE.PRESENTATION} diff --git a/src/components/KeyboardAvoidingView/BaseKeyboardAvoidingView/index.android.tsx b/src/components/KeyboardAvoidingView/BaseKeyboardAvoidingView/index.android.tsx index 5f185d18cd8e..72dcf3a68482 100644 --- a/src/components/KeyboardAvoidingView/BaseKeyboardAvoidingView/index.android.tsx +++ b/src/components/KeyboardAvoidingView/BaseKeyboardAvoidingView/index.android.tsx @@ -1,4 +1,4 @@ -import type React from 'react'; +import React from 'react'; import type {KeyboardAvoidingViewProps} from 'react-native-keyboard-controller'; import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native-keyboard-controller'; diff --git a/src/components/KeyboardAvoidingView/BaseKeyboardAvoidingView/index.ios.tsx b/src/components/KeyboardAvoidingView/BaseKeyboardAvoidingView/index.ios.tsx index f0136d4e6cba..9d33cb3c91e1 100644 --- a/src/components/KeyboardAvoidingView/BaseKeyboardAvoidingView/index.ios.tsx +++ b/src/components/KeyboardAvoidingView/BaseKeyboardAvoidingView/index.ios.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native'; -import type {KeyboardAvoidingViewProps} from '@components/KeyboardAvoidingView/types'; +import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native-keyboard-controller'; +import type {KeyboardAvoidingViewProps} from 'react-native-keyboard-controller'; function BaseKeyboardAvoidingView(props: KeyboardAvoidingViewProps) { // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 15b5cf32d0bd..cb922e075c77 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -343,6 +343,7 @@ function MoneyRequestConfirmationListFooter({ reportNameValuePairs: undefined, action: undefined, checkIfContextMenuActive: () => {}, + onShowContextMenu: () => {}, isDisabled: true, shouldDisplayContextMenu: false, }), diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 2e806d088d05..dbf8209d4386 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -3,7 +3,7 @@ import lodashIsEqual from 'lodash/isEqual'; import type {ReactNode, RefObject} from 'react'; import React, {useCallback, useLayoutEffect, useMemo, useState} from 'react'; import {StyleSheet, View} from 'react-native'; -import type {GestureResponderEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {ModalProps} from 'react-native-modal'; import type {SvgProps} from 'react-native-svg'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; @@ -75,6 +75,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; + /** Callback method fired when the modal is shown */ onModalShow?: () => void; @@ -173,6 +176,7 @@ function PopoverMenu({ anchorPosition, anchorRef, onClose, + onLayout, onModalShow, headerText, fromSidebarMediumScreen, @@ -415,7 +419,10 @@ function PopoverMenu({ testID={testID} > - + {renderHeaderText()} {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} {renderWithConditionalWrapper(shouldUseScrollView, scrollContainerStyle, renderedMenuItems)} diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx index 3b6a87498fab..c80059c1a33c 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 usePrevious from '@hooks/usePrevious'; @@ -9,6 +9,7 @@ import ComposerFocusManager from '@libs/ComposerFocusManager'; import PopoverWithMeasuredContentUtils from '@libs/PopoverWithMeasuredContentUtils'; import CONST from '@src/CONST'; import type {AnchorDimensions, AnchorPosition} from '@src/styles'; +import * as ActionSheetAwareScrollView from './ActionSheetAwareScrollView'; import type {PopoverAnchorPosition} from './Modal/types'; import Popover from './Popover'; import type PopoverProps from './Popover/types'; @@ -67,6 +68,7 @@ function PopoverWithMeasuredContent({ shouldMeasureAnchorPositionFromTop = false, ...props }: PopoverWithMeasuredContentProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const {windowWidth, windowHeight} = useWindowDimensions(); const [popoverWidth, setPopoverWidth] = useState(popoverDimensions.width); @@ -88,9 +90,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.get().current.payload?.popoverHeight !== Math.floor(height) && height !== 0) { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.MEASURE_POPOVER, + payload: { + popoverHeight: Math.floor(height), + }, + }); + } }; const adjustedAnchorPosition = useMemo(() => { diff --git a/src/components/ReportActionItem/MoneyRequestAction.tsx b/src/components/ReportActionItem/MoneyRequestAction.tsx index 6850a53b73e0..757f121b5826 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.tsx +++ b/src/components/ReportActionItem/MoneyRequestAction.tsx @@ -48,6 +48,9 @@ type MoneyRequestActionProps = { /** 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 the IOU is hovered so we can modify its style */ isHovered?: boolean; @@ -68,6 +71,7 @@ function MoneyRequestAction({ reportID, isMostRecentIOUReportAction, contextMenuAnchor, + onShowContextMenu = () => {}, checkIfContextMenuActive = () => {}, isHovered = false, style, @@ -141,6 +145,7 @@ function MoneyRequestAction({ isTrackExpense={isTrackExpenseAction} 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 06ab0d0e98b9..265fdf562c1f 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -94,6 +94,7 @@ function MoneyRequestPreviewContent({ onPreviewPressed, containerStyles, checkIfContextMenuActive = () => {}, + onShowContextMenu = () => {}, shouldShowPendingConversionMessage = false, isHovered = false, isWhisper = false, @@ -221,7 +222,7 @@ function MoneyRequestPreviewContent({ if (!shouldDisplayContextMenu) { return; } - showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive); + onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive)); }; const getTranslatedText = (item: TranslationPathOrText) => (item.translationPath ? translate(item.translationPath) : item.text ?? ''); diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts index 186c81a8c866..d6f6af1079d3 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts @@ -27,6 +27,9 @@ type MoneyRequestPreviewProps = { /** 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 d5f4e8e88107..0a054721fc7d 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -122,6 +122,9 @@ type ReportPreviewProps = { /** 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; + /** Callback when the payment options popover is shown */ onPaymentOptionsShow?: () => void; @@ -150,6 +153,7 @@ function ReportPreview({ checkIfContextMenuActive = () => {}, onPaymentOptionsShow, onPaymentOptionsHide, + onShowContextMenu = () => {}, shouldDisplayContextMenu = true, }: ReportPreviewProps) { const policy = usePolicy(policyID); @@ -633,13 +637,14 @@ function ReportPreview({ onPress={openReportFromPreview} onPressIn={() => canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => { - if (!shouldDisplayContextMenu) { - return; - } - - showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive); - }} + onLongPress={(event) => + onShowContextMenu(() => { + if (!shouldDisplayContextMenu) { + return; + } + return showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive); + }) + } shouldUseHapticsOnLongPress // This is added to omit console error about nested buttons as its forbidden on web platform style={[styles.flexRow, styles.justifyContentBetween, styles.reportPreviewBox, isOnSearch ? styles.borderedContentCardLarge : {}]} diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index 3c122a4cab2a..356eeb8f7ff0 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -55,6 +55,9 @@ 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; + /** Style for the task preview container */ style: StyleProp; @@ -69,6 +72,7 @@ function TaskPreview({ chatReportID, checkIfContextMenuActive, currentUserPersonalDetails, + onShowContextMenu, isHovered = false, style, shouldDisplayContextMenu = true, @@ -116,12 +120,14 @@ function TaskPreview({ onPress={() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(taskReportID, undefined, undefined, undefined, undefined, Navigation.getActiveRoute()))} onPressIn={() => canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => { - if (!shouldDisplayContextMenu) { - return; - } - showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive); - }} + onLongPress={(event) => + onShowContextMenu(() => { + if (!shouldDisplayContextMenu) { + return; + } + return showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive); + }) + } shouldUseHapticsOnLongPress style={[styles.flexRow, styles.justifyContentBetween, style]} role={CONST.ROLE.BUTTON} diff --git a/src/components/ReportActionItem/TaskView.tsx b/src/components/ReportActionItem/TaskView.tsx index 396ed9790d24..29e7021a06d4 100644 --- a/src/components/ReportActionItem/TaskView.tsx +++ b/src/components/ReportActionItem/TaskView.tsx @@ -73,6 +73,7 @@ function TaskView({report, action}: TaskViewProps) { transactionThreadReport: undefined, checkIfContextMenuActive: () => {}, isDisabled: true, + onShowContextMenu: (callback: () => void) => callback(), shouldDisplayContextMenu: false, }), [report, action], diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts index 21b17c2d08a7..6427bd07a8cd 100644 --- a/src/components/ShowContextMenuContext.ts +++ b/src/components/ShowContextMenuContext.ts @@ -16,12 +16,14 @@ type ShowContextMenuContextProps = { action: OnyxEntry; transactionThreadReport?: OnyxEntry; checkIfContextMenuActive: () => void; + onShowContextMenu: (callback: () => void) => void; isDisabled: boolean; shouldDisplayContextMenu?: boolean; }; const ShowContextMenuContext = createContext({ anchor: null, + onShowContextMenu: (callback) => callback(), report: undefined, reportNameValuePairs: undefined, action: undefined, diff --git a/src/components/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx index 775cb76cf298..ea906b909f76 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import {getButtonRole} from '@components/Button/utils'; @@ -55,12 +55,12 @@ function ThreeDotsMenu({ setPopupMenuVisible(true); }; - const hidePopoverMenu = (selectedItem?: PopoverMenuItem) => { + const hidePopoverMenu = useCallback((selectedItem?: PopoverMenuItem) => { if (selectedItem && selectedItem.shouldKeepModalOpen) { return; } setPopupMenuVisible(false); - }; + }, []); useImperativeHandle(threeDotsMenuRef as React.RefObject<{hidePopoverMenu: () => void; isPopupMenuVisible: boolean}> | undefined, () => ({ isPopupMenuVisible, @@ -72,7 +72,7 @@ function ThreeDotsMenu({ return; } hidePopoverMenu(); - }, [isBehindModal, isPopupMenuVisible]); + }, [hidePopoverMenu, isBehindModal, isPopupMenuVisible]); const onThreeDotsPress = () => { if (isPopupMenuVisible) { diff --git a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx index c015829ab675..d7115921f58a 100644 --- a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx +++ b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx @@ -45,7 +45,7 @@ function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel, isDele )} {!isDeleted ? ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled, shouldDisplayContextMenu}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled, onShowContextMenu, shouldDisplayContextMenu}) => ( { + showContextMenuForReport(event, anchor, report?.reportID, action, checkIfContextMenuActive, isArchivedNonExpenseReport(report, reportNameValuePairs)); + }); }} shouldUseHapticsOnLongPress > diff --git a/src/hooks/useRestoreInputFocus.ts b/src/hooks/useRestoreInputFocus/index.android.ts similarity index 100% rename from src/hooks/useRestoreInputFocus.ts rename to src/hooks/useRestoreInputFocus/index.android.ts diff --git a/src/hooks/useRestoreInputFocus/index.ts b/src/hooks/useRestoreInputFocus/index.ts new file mode 100644 index 000000000000..4105455698dc --- /dev/null +++ b/src/hooks/useRestoreInputFocus/index.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const useRestoreInputFocus = (_isLostFocus: boolean) => {}; + +export default useRestoreInputFocus; diff --git a/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.native.ts b/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.native.ts new file mode 100644 index 000000000000..eab78097aa05 --- /dev/null +++ b/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.native.ts @@ -0,0 +1,3 @@ +import {executeOnUIRuntimeSync} from 'react-native-reanimated'; + +export default executeOnUIRuntimeSync; diff --git a/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.ts b/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.ts new file mode 100644 index 000000000000..b3db44cca5dd --- /dev/null +++ b/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.ts @@ -0,0 +1,7 @@ +// executeOnUIRuntimeSync crashes on web not because browsers lack a UI thread concept, +// but because this specific function attempts to use direct, synchronous thread communication +// methods that don't exist in browsers +// runOnUI works on web because it's designed with proper cross-platform compatibility +import {runOnUI} from 'react-native-reanimated'; + +export default runOnUI; diff --git a/src/hooks/useWorkletStateMachine/index.ts b/src/hooks/useWorkletStateMachine/index.ts new file mode 100644 index 000000000000..ba78b184bf6b --- /dev/null +++ b/src/hooks/useWorkletStateMachine/index.ts @@ -0,0 +1,172 @@ +import fastMerge from 'expensify-common/dist/fastMerge'; +import {useCallback} from 'react'; +import {runOnJS, runOnUI, useSharedValue} from 'react-native-reanimated'; +import Log from '@libs/Log'; +import executeOnUIRuntimeSync from './executeOnUIRuntimeSync'; + +// 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

; +}; + +/** + * Represents the state machine configuration as a nested record where: + * - The first level keys are the state names. + * - The second level keys are the action types valid for that state. + * - The corresponding values are the next states to transition to when the action is triggered. + */ +type StateMachine = { + [K in S]?: { + [K2 in A]?: S; + }; +}; + +// eslint-disable-next-line @typescript-eslint/unbound-method +const client = runOnJS(Log.client); + +/** + * A hook that creates a state machine that can be used with Reanimated Worklets, useful for when you need to keep the native thread and JS tightly in-sync. + * You can transition state from worklets running on the UI thread, 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. + * 3. There is no handling for this action in idle state so we do nothing. + * 4. We close emoji picker and it sends EMOJI_PICKER_CLOSE action which again does nothing. + * 5. We open keyboard. It sends KEYBOARD_OPEN action. idle can react to this action + * by transitioning into keyboardOpen state + * 6. Our state is keyboardOpen. It can react to KEYBOARD_CLOSE, EMOJI_PICKER_OPEN actions + * 7. 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. + * 8. 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 + * 9. 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, P>(stateMachine: SM, 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 + client(`[StateMachine] ${message}. Params: ${JSON.stringify(params)}`); + }, []); + + const transitionWorklet = useCallback( + (action: ActionWithPayload

) => { + 'worklet'; + + if (!action) { + throw new Error('state machine action is required'); + } + + const state = currentState.get(); + + 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; + } + + // save previous payload or merge the new payload with the previous payload + const nextPayload = typeof action.payload === 'undefined' ? state.current.payload : fastMerge(state.current.payload, action.payload); + + log(`Next STATE: ${nextState}`, nextPayload); + + currentState.set({ + previous: state.current, + current: { + state: nextState, + payload: nextPayload, + }, + }); + }, + [currentState, log, stateMachine], + ); + + const resetWorklet = useCallback(() => { + 'worklet'; + + log('RESET STATE MACHINE'); + currentState.set(initialState); + }, [currentState, initialState, log]); + + const reset = useCallback(() => { + runOnUI(resetWorklet)(); + }, [resetWorklet]); + + const transition = useCallback( + (action: ActionWithPayload

) => { + executeOnUIRuntimeSync(transitionWorklet)(action); + }, + [transitionWorklet], + ); + + return { + currentState, + transitionWorklet, + transition, + reset, + }; +} + +export type {ActionWithPayload, State, StateMachine}; +export default useWorkletStateMachine; diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx index 734d409380e9..b1f4e0518814 100644 --- a/src/pages/TransactionDuplicate/Confirmation.tsx +++ b/src/pages/TransactionDuplicate/Confirmation.tsx @@ -88,6 +88,7 @@ function Confirmation() { action: reportAction, report, checkIfContextMenuActive: () => {}, + onShowContextMenu: () => {}, reportNameValuePairs: undefined, anchor: null, isDisabled: false, diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 2a236649aa07..9d15e42987d5 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 {useOnyx} from 'react-native-onyx'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import type {ContextMenuItemHandle} from '@components/ContextMenuItem'; import ContextMenuItem from '@components/ContextMenuItem'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; @@ -128,6 +129,7 @@ function BaseReportActionContextMenu({ disabledActions = [], setIsEmojiPickerActive, }: BaseReportActionContextMenuProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -335,6 +337,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 2d903dbc4b03..bf476afbe50e 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -175,6 +175,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; diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx index 01c14fe04973..a1e1450dfbb0 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -1,11 +1,12 @@ /* eslint-disable react-compiler/react-compiler */ import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState} from 'react'; /* eslint-disable no-restricted-imports */ import type {EmitterSubscription, GestureResponderEvent, NativeTouchEvent, View} from 'react-native'; import {DeviceEventEmitter, Dimensions} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {Actions, ActionSheetAwareScrollViewContext} from '@components/ActionSheetAwareScrollView'; import ConfirmModal from '@components/ConfirmModal'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import useLocalize from '@hooks/useLocalize'; @@ -52,6 +53,7 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef void) => { + if (!(popoverAnchorRef.current && 'measureInWindow' in popoverAnchorRef.current)) { + return; + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + popoverAnchorRef.current?.measureInWindow((_fx, frameY, _width, height) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.OPEN_POPOVER, + payload: { + popoverHeight: 0, + frameY, + height, + }, + }); + + callback(); + }); + }, + [actionSheetAwareScrollViewContext], + ); + const disabledActions = useMemo(() => (!canWriteInReport(report) ? RestrictedReadOnlyContextMenuActions : []), [report]); /** @@ -571,30 +596,32 @@ function PureReportActionItem({ return; } - setIsContextMenuActive(true); - const selection = SelectionScraper.getCurrentSelection(); - showContextMenu({ - type: CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - selection, - contextMenuAnchor: popoverAnchorRef.current, - report: { - reportID, - originalReportID, - isArchivedRoom, - isChronos: isChronosReport, - }, - reportAction: { - reportActionID: action.reportActionID, - draftMessage, - isThreadReportParentAction, - }, - callbacks: { - onShow: toggleContextMenuFromActiveReportAction, - onHide: toggleContextMenuFromActiveReportAction, - setIsEmojiPickerActive: setIsEmojiPickerActive as () => void, - }, - disabledOptions: disabledActions, + handleShowContextMenu(() => { + setIsContextMenuActive(true); + const selection = SelectionScraper.getCurrentSelection(); + showContextMenu({ + type: CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + event, + selection, + contextMenuAnchor: popoverAnchorRef.current, + report: { + reportID, + originalReportID, + isArchivedRoom, + isChronos: isChronosReport, + }, + reportAction: { + reportActionID: action.reportActionID, + draftMessage, + isThreadReportParentAction, + }, + callbacks: { + onShow: toggleContextMenuFromActiveReportAction, + onHide: toggleContextMenuFromActiveReportAction, + setIsEmojiPickerActive: setIsEmojiPickerActive as () => void, + }, + disabledOptions: disabledActions, + }); }); }, [ @@ -607,6 +634,7 @@ function PureReportActionItem({ disabledActions, isArchivedRoom, isChronosReport, + handleShowContextMenu, isThreadReportParentAction, ], ); @@ -626,10 +654,11 @@ function PureReportActionItem({ action, transactionThreadReport, checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, + onShowContextMenu: handleShowContextMenu, isDisabled: false, shouldDisplayContextMenu, }), - [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport, reportNameValuePairs, shouldDisplayContextMenu], + [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport, handleShowContextMenu, reportNameValuePairs, shouldDisplayContextMenu], ); const attachmentContextValue = useMemo(() => { @@ -801,6 +830,7 @@ function PureReportActionItem({ isMostRecentIOUReportAction={isMostRecentIOUReportAction} isHovered={hovered} contextMenuAnchor={popoverAnchorRef.current} + onShowContextMenu={handleShowContextMenu} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} style={displayAsGroup ? [] : [styles.mt2]} isWhisper={isWhisper} @@ -884,6 +914,7 @@ function PureReportActionItem({ containerStyles={displayAsGroup ? [] : [styles.mt2]} action={action} isHovered={hovered} + onShowContextMenu={handleShowContextMenu} contextMenuAnchor={popoverAnchorRef.current} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} onPaymentOptionsShow={() => setIsPaymentMethodPopoverActive(true)} @@ -903,6 +934,7 @@ function PureReportActionItem({ chatReportID={reportID} action={action} isHovered={hovered} + onShowContextMenu={handleShowContextMenu} contextMenuAnchor={popoverAnchorRef.current} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} policyID={report?.policyID} diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 91c34d0d063a..a586593f452f 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,12 +1,13 @@ import lodashDebounce from 'lodash/debounce'; import noop from 'lodash/noop'; -import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native'; +import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import type {LayoutChangeEvent, MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import {runOnUI, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import type {FileObject} from '@components/AttachmentModal'; import AttachmentModal from '@components/AttachmentModal'; import DropZoneUI from '@components/DropZoneUI'; @@ -111,6 +112,7 @@ function ReportActionCompose({ onComposerBlur, didHideComposerInput, }: ReportActionComposeProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -359,6 +361,18 @@ function ReportActionCompose({ clearComposer(); }, [isSendDisabled, isReportReadyForDisplay, composerRefShared]); + const measureComposer = useCallback( + (e: LayoutChangeEvent) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.MEASURE_COMPOSER, + payload: { + composerHeight: e.nativeEvent.layout.height, + }, + }); + }, + [actionSheetAwareScrollViewContext], + ); + // eslint-disable-next-line react-compiler/react-compiler onSubmitAction = handleSendMessage; @@ -401,7 +415,10 @@ function ReportActionCompose({ {shouldShowReportRecipientLocalTime && hasReportRecipient && } - + {}; jest.mock('@rnmapbox/maps', () => { return { default: jest.fn(), @@ -76,7 +77,8 @@ describe('ReportPreview', () => { chatReportID={chatReportID} action={createRandomReportAction(0)} policyID="" - checkIfContextMenuActive={() => {}} + checkIfContextMenuActive={emptyFunction} + onShowContextMenu={emptyFunction} /> , diff --git a/tests/unit/VideoRendererTest.tsx b/tests/unit/VideoRendererTest.tsx index f480cdd4dbd1..31ff889fc97d 100644 --- a/tests/unit/VideoRendererTest.tsx +++ b/tests/unit/VideoRendererTest.tsx @@ -42,6 +42,7 @@ const mockShowContextMenuValue = { transactionThreadReport: undefined, checkIfContextMenuActive: () => {}, isDisabled: true, + onShowContextMenu: (callback: () => void) => callback(), }; const mockTNodeAttributes = { [CONST.ATTACHMENT_SOURCE_ATTRIBUTE]: 'video/test.mp4',