diff --git a/patches/react-native-reanimated+3.16.3+001+mock-useDerivedValue-getter.patch b/patches/react-native-reanimated+3.16.3+001+mock-useDerivedValue-getter.patch new file mode 100644 index 000000000000..972ddeedf67a --- /dev/null +++ b/patches/react-native-reanimated+3.16.3+001+mock-useDerivedValue-getter.patch @@ -0,0 +1,18 @@ +diff --git a/node_modules/react-native-reanimated/src/mock.ts b/node_modules/react-native-reanimated/src/mock.ts +index 3d8e3f8..5eba613 100644 +--- a/node_modules/react-native-reanimated/src/mock.ts ++++ b/node_modules/react-native-reanimated/src/mock.ts +@@ -87,7 +87,12 @@ const hook = { + useAnimatedReaction: NOOP, + useAnimatedRef: () => ({ current: null }), + useAnimatedScrollHandler: NOOP_FACTORY, +- useDerivedValue: (processor: () => Value) => ({ value: processor() }), ++ // https://github.com/software-mansion/react-native-reanimated/pull/6809 ++ useDerivedValue: (processor: () => Value) => { ++ const result = processor(); ++ ++ return { value: result, get: () => result }; ++ }, + useAnimatedSensor: () => ({ + sensor: { + value: { diff --git a/src/App.tsx b/src/App.tsx index 52904e0a06c4..15a1dcd81ac3 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/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; @@ -88,6 +89,7 @@ function App({url}: AppProps) { CustomStatusBarAndBackgroundContextProvider, ActiveElementRoleProvider, ActiveWorkspaceContextProvider, + ActionSheetAwareScrollView.ActionSheetAwareScrollViewProvider, ReportIDsContextProvider, PlaybackContextProvider, FullScreenContextProvider, diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx new file mode 100644 index 000000000000..02b8b4c1f97b --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -0,0 +1,139 @@ +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 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', +}; + +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', +}; + +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, + }, + [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..e15ac941a09d --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -0,0 +1,223 @@ +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, 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 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}; +}; + +const useSafeAreaPaddings = () => { + const StyleUtils = useStyleUtils(); + const insets = useSafeAreaInsets(); + const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets ?? undefined); + + return {top: paddingTop, bottom: paddingBottom}; +}; + +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 = 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() - safeArea.bottom; + + // Sometimes we need to know the last keyboard height + const lastKeyboardHeight = keyboard.heightWhenOpened.get() - safeArea.bottom; + const {popoverHeight = 0, fy, height} = current.payload ?? {}; + const invertedKeyboardHeight = keyboard.state.get() === KeyboardState.CLOSED ? lastKeyboardHeight : 0; + const elementOffset = fy !== undefined && height !== undefined && popoverHeight !== undefined ? fy + safeArea.top + 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.fy !== undefined && previousPayload.height !== undefined && previousPayload.popoverHeight !== undefined + ? previousPayload.fy + safeArea.top + 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)) { + return Math.max(keyboard.heightWhenOpened.get() - keyboard.height.get() - safeArea.bottom, 0) + Math.max(elementOffset, 0); + } + 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) { + return withSpring(elementOffset < 0 ? 0 : elementOffset, SPRING_CONFIG); + } + + return withSpring(Math.max(previousElementOffset, 0), SPRING_CONFIG); + } + + return 0; + } + + case States.KEYBOARD_POPOVER_OPEN: { + if (keyboard.state.get() === KeyboardState.OPEN) { + return withSpring(0, SPRING_CONFIG); + } + + const nextOffset = elementOffset + lastKeyboardHeight; + + if (keyboard.state.get() === KeyboardState.CLOSED && nextOffset > invertedKeyboardHeight) { + return withSpring(nextOffset < 0 ? 0 : nextOffset, SPRING_CONFIG); + } + + if (elementOffset < 0) { + return isClosingKeyboard ? 0 : 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) { + return elementOffset + lastKeyboardHeight; + } + + if (keyboard.height.get() > 0) { + return keyboard.heightWhenOpened.get() - keyboard.height.get() + elementOffset; + } + + 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..2c40df7e61c6 --- /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 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..a10e7d9fd1f3 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -1,8 +1,9 @@ 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'; @@ -20,7 +21,7 @@ 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; @@ -31,12 +32,41 @@ type EmojiPickerButtonProps = { }; 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(); + const openEmojiPicker: PressableProps['onPress'] = (e) => { + if (!isFocused) { + return; + } + + actionSheetContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_KEYBOARD, + }); + + 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); + }; + useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []); return ( @@ -45,28 +75,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 c27eef1de91e..128ebd2d3a84 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -94,7 +94,7 @@ function ImageRenderer({tnode}: ImageRendererProps) { thumbnailImageComponent ) : ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( + {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( {({reportID, accountID, type}) => ( + showContextMenuForReport( + event, + anchor, + report?.reportID ?? '-1', + action, + checkIfContextMenuActive, + ReportUtils.isArchivedRoom(report, reportNameValuePairs), + ), + ); }} shouldUseHapticsOnLongPress accessibilityRole={CONST.ROLE.BUTTON} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 96bdf8e9e1e8..29c1d290fa5f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -84,14 +84,16 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona return ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( + {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( { if (isDisabled) { return; } - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); + return onShowContextMenu(() => + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)), + ); }} onPress={(event) => { event.preventDefault(); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index b1e5c21500f0..b7c428e72f29 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -34,16 +34,25 @@ function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...d return ( - {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( + {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled}) => ( {})} onPressIn={onPressIn} onPressOut={onPressOut} onLongPress={(event) => { - if (isDisabled) { - return; - } - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)); + onShowContextMenu(() => { + if (isDisabled) { + return; + } + return showContextMenuForReport( + event, + anchor, + report?.reportID ?? '-1', + action, + checkIfContextMenuActive, + ReportUtils.isArchivedRoom(report, reportNameValuePairs), + ); + }); }} shouldUseHapticsOnLongPress role={CONST.ROLE.PRESENTATION} diff --git a/src/components/KeyboardAvoidingView/index.ios.tsx b/src/components/KeyboardAvoidingView/index.ios.tsx index 171210eab7ac..68cfa73e90b5 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/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 340be8a6c3e1..c8279bf76e0f 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -278,6 +278,7 @@ function MoneyRequestConfirmationListFooter({ reportNameValuePairs: undefined, action: undefined, checkIfContextMenuActive: () => {}, + onShowContextMenu: () => {}, isDisabled: true, }), [], diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 7432c683e0a7..11ba9542de45 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, {useLayoutEffect, useState} from 'react'; import {StyleSheet, View} from 'react-native'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {ModalProps} from 'react-native-modal'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -62,6 +62,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; @@ -151,6 +154,7 @@ function PopoverMenu({ anchorPosition, anchorRef, onClose, + onLayout, onModalShow, headerText, fromSidebarMediumScreen, @@ -359,7 +363,10 @@ function PopoverMenu({ shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} > - + {renderHeaderText()} {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} {renderWithConditionalWrapper(shouldUseScrollView, scrollContainerStyle, renderedMenuItems)} diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx index 4fa58ac21ffa..80b9fb1a9564 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'; @@ -8,6 +8,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 Popover from './Popover'; import type PopoverProps from './Popover/types'; @@ -61,6 +62,7 @@ function PopoverWithMeasuredContent({ shouldEnableNewFocusManagement, ...props }: PopoverWithMeasuredContentProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const {windowWidth, windowHeight} = useWindowDimensions(); const [popoverWidth, setPopoverWidth] = useState(popoverDimensions.width); @@ -89,9 +91,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 af54e2940d3f..e17c30e8bddb 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.tsx +++ b/src/components/ReportActionItem/MoneyRequestAction.tsx @@ -51,6 +51,9 @@ type MoneyRequestActionProps = MoneyRequestActionOnyxProps & { /** 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; @@ -71,6 +74,7 @@ function MoneyRequestAction({ reportID, isMostRecentIOUReportAction, contextMenuAnchor, + onShowContextMenu = () => {}, checkIfContextMenuActive = () => {}, chatReport, iouReport, @@ -129,6 +133,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 44e3b7488ba3..f62e0df20b33 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -60,6 +60,7 @@ function MoneyRequestPreviewContent({ onPreviewPressed, containerStyles, checkIfContextMenuActive = () => {}, + onShowContextMenu = () => {}, shouldShowPendingConversionMessage = false, isHovered = false, isWhisper = false, @@ -188,7 +189,7 @@ function MoneyRequestPreviewContent({ if (!shouldDisplayContextMenu) { return; } - 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 c40b45c6d2bd..7f19120426c1 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 badab47b1c35..181e30bcfc9b 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -70,6 +70,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; @@ -95,6 +98,7 @@ function ReportPreview({ checkIfContextMenuActive = () => {}, onPaymentOptionsShow, onPaymentOptionsHide, + onShowContextMenu = () => {}, }: ReportPreviewProps) { const policy = usePolicy(policyID); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`); @@ -465,7 +469,7 @@ function ReportPreview({ onPress={openReportFromPreview} 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 f6f436cbd51e..5007548ca4fd 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -57,11 +57,24 @@ 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; }; -function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, checkIfContextMenuActive, currentUserPersonalDetails, isHovered = false, style}: TaskPreviewProps) { +function TaskPreview({ + taskReportID, + action, + contextMenuAnchor, + chatReportID, + checkIfContextMenuActive, + currentUserPersonalDetails, + onShowContextMenu, + isHovered = false, + style, +}: TaskPreviewProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -95,7 +108,7 @@ function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, che 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, style]} role={CONST.ROLE.BUTTON} diff --git a/src/components/SelectionList/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx index a3e04c9088f1..2335473a8f00 100644 --- a/src/components/SelectionList/ChatListItem.tsx +++ b/src/components/SelectionList/ChatListItem.tsx @@ -49,6 +49,7 @@ function ChatListItem({ action: undefined, transactionThreadReport: undefined, checkIfContextMenuActive: () => {}, + onShowContextMenu: () => {}, isDisabled: true, }; diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts index 6fefa987fac3..ee6e7e71dd7a 100644 --- a/src/components/ShowContextMenuContext.ts +++ b/src/components/ShowContextMenuContext.ts @@ -16,11 +16,13 @@ type ShowContextMenuContextProps = { action: OnyxEntry; transactionThreadReport?: OnyxEntry; checkIfContextMenuActive: () => void; + onShowContextMenu: (callback: () => void) => void; isDisabled: boolean; }; const ShowContextMenuContext = createContext({ anchor: null, + onShowContextMenu: (callback) => callback(), report: undefined, reportNameValuePairs: undefined, action: undefined, @@ -62,7 +64,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 e44d57ab18e2..bc062fffd787 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Icon from '@components/Icon'; @@ -43,16 +43,16 @@ function ThreeDotsMenu({ setPopupMenuVisible(true); }; - const hidePopoverMenu = () => { + const hidePopoverMenu = useCallback(() => { setPopupMenuVisible(false); - }; + }, []); useEffect(() => { if (!isBehindModal || !isPopupMenuVisible) { return; } hidePopoverMenu(); - }, [isBehindModal, isPopupMenuVisible]); + }, [hidePopoverMenu, isBehindModal, isPopupMenuVisible]); return ( <> diff --git a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx index 832b5eef45f0..e1c1a000d9bd 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}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive, isDisabled, onShowContextMenu}) => ( { + showContextMenuForReport( + event, + anchor, + report?.reportID ?? '-1', + action, + checkIfContextMenuActive, + ReportUtils.isArchivedRoom(report, reportNameValuePairs), + ); + }); }} shouldUseHapticsOnLongPress > 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..3bc8059d8762 --- /dev/null +++ b/src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.ts @@ -0,0 +1,3 @@ +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..f95a43ef2ae8 --- /dev/null +++ b/src/hooks/useWorkletStateMachine/index.ts @@ -0,0 +1,174 @@ +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

; +}; + +type StateMachine = Record>; + +// eslint-disable-next-line @typescript-eslint/unbound-method +const client = Log.client; + +/** + * 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(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; + } + + 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.set({ + previous: state.current, + current: { + state: nextState, + payload: nextPayload, + }, + }); + }, + [currentState, log, stateMachine], + ); + + const resetWorklet = useCallback(() => { + 'worklet'; + + log('RESET STATE MACHINE'); + // eslint-disable-next-line react-compiler/react-compiler + 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}; +export default useWorkletStateMachine; diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx index 0dbc2598c609..17a52227ae93 100644 --- a/src/pages/TransactionDuplicate/Confirmation.tsx +++ b/src/pages/TransactionDuplicate/Confirmation.tsx @@ -68,6 +68,7 @@ function Confirmation() { action: reportAction, report, checkIfContextMenuActive: () => {}, + onShowContextMenu: () => {}, reportNameValuePairs: undefined, anchor: null, isDisabled: false, diff --git a/src/pages/WorkspaceSwitcherPage/index.tsx b/src/pages/WorkspaceSwitcherPage/index.tsx index 87ba17b6504d..cf06b193f913 100644 --- a/src/pages/WorkspaceSwitcherPage/index.tsx +++ b/src/pages/WorkspaceSwitcherPage/index.tsx @@ -166,6 +166,7 @@ function WorkspaceSwitcherPage() { shouldShowListEmptyContent={shouldShowCreateWorkspace} initiallyFocusedOptionKey={activeWorkspaceID ?? CONST.WORKSPACE_SWITCHER.NAME} showLoadingPlaceholder={fetchStatus.status === 'loading' || !didScreenTransitionEnd} + includeSafeAreaPaddingBottom={false} showConfirmButton={!!activeWorkspaceID} shouldUseDefaultTheme confirmButtonText={translate('workspace.common.clearFilter')} diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 1d7deea43a04..6fa1bdb39540 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'; @@ -117,6 +118,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 @@ -324,6 +326,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 705b85d4c3fc..3f0dec1c136a 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -33,8 +33,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Beta, Download as DownloadOnyx, OnyxInputOrEntry, ReportAction, ReportActionReactions, Transaction, User} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {ContextMenuAnchor} from './ReportActionContextMenu'; import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; +import type {ContextMenuAnchor} from './ReportActionContextMenu'; /** Gets the HTML version of the message in an action */ function getActionHtml(reportAction: OnyxInputOrEntry): string { @@ -80,6 +80,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/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 893d2b3060d9..9be5a17f94ff 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,13 +1,14 @@ import {useNavigation} from '@react-navigation/native'; 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 EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; @@ -116,6 +117,7 @@ function ReportActionCompose({ onComposerFocus, onComposerBlur, }: ReportActionComposeProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -370,6 +372,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; @@ -440,7 +454,10 @@ function ReportActionCompose({ {shouldShowReportRecipientLocalTime && hasReportRecipient && } - + { setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(action.reportActionID)); - }, [action.reportActionID]); + + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_POPOVER, + }); + }, [actionSheetAwareScrollViewContext, action.reportActionID]); + + 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], + ); const isArchivedRoom = ReportUtils.isArchivedRoomWithID(originalReportID); const disabledActions = useMemo(() => (!ReportUtils.canWriteInReport(report) ? RestrictedReadOnlyContextMenuActions : []), [report]); @@ -340,29 +369,31 @@ function ReportActionItem({ return; } - setIsContextMenuActive(true); - const selection = SelectionScraper.getCurrentSelection(); - ReportActionContextMenu.showContextMenu( - CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - selection, - popoverAnchorRef.current, - reportID, - action.reportActionID, - originalReportID, - draftMessage ?? '', - () => setIsContextMenuActive(true), - toggleContextMenuFromActiveReportAction, - isArchivedRoom, - isChronosReport, - false, - false, - disabledActions, - false, - setIsEmojiPickerActive as () => void, - undefined, - isThreadReportParentAction, - ); + handleShowContextMenu(() => { + setIsContextMenuActive(true); + const selection = SelectionScraper.getCurrentSelection(); + ReportActionContextMenu.showContextMenu( + CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + event, + selection, + popoverAnchorRef.current, + reportID, + action.reportActionID, + originalReportID, + draftMessage ?? '', + () => setIsContextMenuActive(true), + toggleContextMenuFromActiveReportAction, + isArchivedRoom, + isChronosReport, + false, + false, + disabledActions, + false, + setIsEmojiPickerActive as () => void, + undefined, + isThreadReportParentAction, + ); + }); }, [ draftMessage, @@ -374,6 +405,7 @@ function ReportActionItem({ disabledActions, isArchivedRoom, isChronosReport, + handleShowContextMenu, isThreadReportParentAction, ], ); @@ -405,9 +437,10 @@ function ReportActionItem({ action, transactionThreadReport, checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, + onShowContextMenu: handleShowContextMenu, isDisabled: false, }), - [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport, reportNameValuePairs], + [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport, handleShowContextMenu, reportNameValuePairs], ); const attachmentContextValue = useMemo(() => ({reportID, type: CONST.ATTACHMENT_TYPE.REPORT}), [reportID]); @@ -549,6 +582,7 @@ function ReportActionItem({ isMostRecentIOUReportAction={isMostRecentIOUReportAction} isHovered={hovered} contextMenuAnchor={popoverAnchorRef.current} + onShowContextMenu={handleShowContextMenu} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} style={displayAsGroup ? [] : [styles.mt2]} isWhisper={isWhisper} @@ -577,6 +611,7 @@ function ReportActionItem({ containerStyles={displayAsGroup ? [] : [styles.mt2]} action={action} isHovered={hovered} + onShowContextMenu={handleShowContextMenu} contextMenuAnchor={popoverAnchorRef.current} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} onPaymentOptionsShow={() => setIsPaymentMethodPopoverActive(true)} @@ -595,6 +630,7 @@ function ReportActionItem({ chatReportID={reportID} action={action} isHovered={hovered} + onShowContextMenu={handleShowContextMenu} contextMenuAnchor={popoverAnchorRef.current} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} policyID={report?.policyID ?? '-1'} diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 9e151a56d329..fcbc23fc80a5 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -7,6 +7,7 @@ import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import InvertedFlatList from '@components/InvertedFlatList'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList'; import {usePersonalDetails} from '@components/OnyxProvider'; @@ -743,6 +744,7 @@ function ReportActionsList({ style={styles.overscrollBehaviorContain} data={sortedVisibleReportActions} renderItem={renderItem} + renderScrollComponent={ActionSheetAwareScrollView.renderScrollComponent} contentContainerStyle={contentContainerStyle} keyExtractor={keyExtractor} initialNumToRender={initialNumToRender}