From 24a9fb35aaf8897dd24f0b4b98b23cbbb95e29ea Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Wed, 13 Mar 2024 15:20:24 +0100 Subject: [PATCH 001/593] fix: messages content overlap when bottom sheet is shown --- ios/Podfile.lock | 3 + jest/setup.ts | 3 + package-lock.json | 11 + package.json | 1 + src/App.tsx | 4 + .../ActionSheetAwareScrollViewContext.tsx | 209 ++++++++++++ .../ActionSheetKeyboardSpace.tsx | 301 ++++++++++++++++++ .../ActionSheetAwareScrollView/index.ios.tsx | 31 ++ .../ActionSheetAwareScrollView/index.tsx | 31 ++ .../BaseAnchorForAttachmentsOnly.tsx | 6 +- .../AttachmentPicker/index.native.tsx | 3 +- src/components/AttachmentPicker/types.ts | 7 + src/components/ConfirmContent.tsx | 25 +- .../EmojiPicker/EmojiPickerButton.tsx | 63 ++-- .../EmojiPickerMenu/index.native.tsx | 21 +- .../HTMLRenderers/ImageRenderer.tsx | 4 +- .../HTMLRenderers/MentionUserRenderer.tsx | 6 +- .../HTMLRenderers/PreRenderer.tsx | 6 +- .../KeyboardAvoidingView/index.ios.tsx | 2 +- src/components/KeyboardHandlerProvider.tsx | 12 + src/components/PopoverMenu.tsx | 10 +- src/components/PopoverWithMeasuredContent.tsx | 21 +- .../Reactions/AddReactionBubble.tsx | 3 +- .../QuickEmojiReactions/index.native.tsx | 28 +- .../Reactions/QuickEmojiReactions/types.ts | 2 +- .../ReportActionItemEmojiReactions.tsx | 12 + .../ReportActionItem/MoneyRequestAction.tsx | 5 + .../MoneyRequestPreviewContent.tsx | 3 +- .../MoneyRequestPreview/types.ts | 3 + .../ReportActionItem/ReportPreview.tsx | 6 +- .../ReportActionItem/TaskPreview.tsx | 17 +- src/components/ShowContextMenuContext.ts | 4 +- src/components/ThreeDotsMenu/index.tsx | 16 +- src/hooks/useWorkletStateMachine.ts | 170 ++++++++++ .../BaseReportActionContextMenu.tsx | 5 +- .../report/ContextMenu/ContextMenuActions.tsx | 25 +- .../AttachmentPickerWithMenuItems.tsx | 18 +- .../ReportActionCompose.tsx | 29 +- src/pages/home/report/ReportActionItem.tsx | 111 +++++-- src/pages/home/report/ReportActionsList.tsx | 2 + 40 files changed, 1151 insertions(+), 88 deletions(-) create mode 100644 src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx create mode 100644 src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx create mode 100644 src/components/ActionSheetAwareScrollView/index.ios.tsx create mode 100644 src/components/ActionSheetAwareScrollView/index.tsx create mode 100644 src/components/KeyboardHandlerProvider.tsx create mode 100644 src/hooks/useWorkletStateMachine.ts diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1ea6b65a58b7..1cac95e15036 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2137,6 +2137,7 @@ DEPENDENCIES: - "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)" - react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-key-command (from `../node_modules/react-native-key-command`) + - react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`) - react-native-launch-arguments (from `../node_modules/react-native-launch-arguments`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-pager-view (from `../node_modules/react-native-pager-view`) @@ -2335,6 +2336,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-image-picker" react-native-key-command: :path: "../node_modules/react-native-key-command" + react-native-keyboard-controller: + :path: "../node_modules/react-native-keyboard-controller" react-native-launch-arguments: :path: "../node_modules/react-native-launch-arguments" react-native-netinfo: diff --git a/jest/setup.ts b/jest/setup.ts index 174e59a7e493..107540140f1d 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -50,6 +50,9 @@ jest.mock('react-native-sound', () => { return SoundMock; }); +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')); + jest.mock('react-native-share', () => ({ default: jest.fn(), })); diff --git a/package-lock.json b/package-lock.json index 8ceeacdbc086..d3bcfea84c51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,6 +98,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", + "react-native-keyboard-controller": "^1.10.4", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", @@ -31419,6 +31420,16 @@ "version": "5.0.1", "license": "MIT" }, + "node_modules/react-native-keyboard-controller": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.10.4.tgz", + "integrity": "sha512-PQ3AcKGnacDBeA1zB1y44XLgj0sZd3Py5Kpml412bKgYiM09JgoK7YbJcUxMayTeEGtZ8GTOteevGTbGq1Otrg==", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-reanimated": ">=2.3.0" + } + }, "node_modules/react-native-launch-arguments": { "version": "4.0.2", "license": "MIT", diff --git a/package.json b/package.json index a8e16bb7baa6..efadc759b85f 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", + "react-native-keyboard-controller": "^1.10.4", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", diff --git a/src/App.tsx b/src/App.tsx index 6316fa80fba1..2759913e7207 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; +import * as ActionSheetAwareScrollView from './components/ActionSheetAwareScrollView'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; import ActiveWorkspaceContextProvider from './components/ActiveWorkspace/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; @@ -14,6 +15,7 @@ import CustomStatusBarAndBackgroundContextProvider from './components/CustomStat import ErrorBoundary from './components/ErrorBoundary'; import HTMLEngineProvider from './components/HTMLEngineProvider'; import InitialURLContextProvider from './components/InitialURLContextProvider'; +import KeyboardHandlerProvider from './components/KeyboardHandlerProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; @@ -79,6 +81,8 @@ function App({url}: AppProps) { CustomStatusBarAndBackgroundContextProvider, ActiveElementRoleProvider, ActiveWorkspaceContextProvider, + KeyboardHandlerProvider, + 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..a8dda4adb621 --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -0,0 +1,209 @@ +import noop from 'lodash/noop'; +import PropTypes from 'prop-types'; +import type {PropsWithChildren} from 'react'; +import React, {createContext, useMemo} from 'react'; +import type {SharedValue} from 'react-native-reanimated'; +import type {ActionWithPayload, State} from '@hooks/useWorkletStateMachine'; +import useWorkletStateMachine from '@hooks/useWorkletStateMachine'; + +type MeasuredElements = { + fy?: number; + popoverHeight?: number; + height?: number; + composerHeight?: number; +}; +type Context = { + currentActionSheetState: SharedValue>; + transitionActionSheetState: (action: ActionWithPayload) => void; + transitionActionSheetStateWorklet: (action: ActionWithPayload) => void; + resetStateMachine: () => void; +}; +const defaultValue: Context = { + currentActionSheetState: { + value: { + previous: { + state: 'idle', + payload: null, + }, + current: { + state: 'idle', + payload: null, + }, + }, + addListener: noop, + removeListener: noop, + modify: noop, + }, + transitionActionSheetState: noop, + transitionActionSheetStateWorklet: noop, + resetStateMachine: noop, +}; + +const ActionSheetAwareScrollViewContext = createContext(defaultValue); + +const Actions = { + OPEN_KEYBOARD: 'KEYBOARD_OPEN', + CLOSE_KEYBOARD: 'CLOSE_KEYBOARD', + OPEN_POPOVER: 'OPEN_POPOVER', + CLOSE_POPOVER: 'CLOSE_POPOVER', + MEASURE_POPOVER: 'MEASURE_POPOVER', + MEASURE_COMPOSER: 'MEASURE_COMPOSER', + POPOVER_ANY_ACTION: 'POPOVER_ANY_ACTION', + OPEN_EMOJI_PICKER_POPOVER: 'OPEN_EMOJI_PICKER_POPOVER', + OPEN_EMOJI_PICKER_POPOVER_STANDALONE: 'OPEN_EMOJI_PICKER_POPOVER_STANDALONE', + CLOSE_EMOJI_PICKER_POPOVER: 'CLOSE_EMOJI_PICKER_POPOVER', + MEASURE_EMOJI_PICKER_POPOVER: 'MEASURE_EMOJI_PICKER_POPOVER', + HIDE_WITHOUT_ANIMATION: 'HIDE_WITHOUT_ANIMATION', + EDIT_REPORT: 'EDIT_REPORT', + SHOW_DELETE_CONFIRM_MODAL: 'SHOW_DELETE_CONFIRM_MODAL', + END_TRANSITION: 'END_TRANSITION', + OPEN_CALL_POPOVER: 'OPEN_CALL_POPOVER', + CLOSE_CONFIRM_MODAL: 'CLOSE_CONFIRM_MODAL', + MEASURE_CONFIRM_MODAL: 'MEASURE_CONFIRM_MODAL', + SHOW_ATTACHMENTS_POPOVER: 'SHOW_ATTACHMENTS_POPOVER', + CLOSE_ATTACHMENTS_POPOVER: 'CLOSE_ATTACHMENTS_POPOVER', + SHOW_ATTACHMENTS_PICKER_POPOVER: 'SHOW_ATTACHMENTS_PICKER_POPOVER', + CLOSE_EMOJI_PICKER_POPOVER_STANDALONE: 'CLOSE_EMOJI_PICKER_POPOVER_STANDALONE', + MEASURE_CALL_POPOVER: 'MEASURE_CALL_POPOVER', + CLOSE_CALL_POPOVER: 'CLOSE_CALL_POPOVER', +}; + +const States = { + IDLE: 'idle', + KEYBOARD_OPEN: 'keyboardOpen', + POPOVER_OPEN: 'popoverOpen', + POPOVER_CLOSED: 'popoverClosed', + KEYBOARD_POPOVER_CLOSED: 'keyboardPopoverClosed', + KEYBOARD_POPOVER_OPEN: 'keyboardPopoverOpen', + KEYBOARD_CLOSED_POPOVER: 'keyboardClosingPopover', + POPOVER_MEASURED: 'popoverMeasured', + EMOJI_PICKER_POPOVER_OPEN: 'emojiPickerPopoverOpen', + DELETE_MODAL_OPEN: 'deleteModalOpen', + DELETE_MODAL_WITH_KEYBOARD_OPEN: 'deleteModalWithKeyboardOpen', + EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN: 'emojiPickerPopoverWithKeyboardOpen', + EMOJI_PICKER_WITH_KEYBOARD_OPEN: 'emojiPickerWithKeyboardOpen', + CALL_POPOVER_WITH_KEYBOARD_OPEN: 'callPopoverWithKeyboardOpen', + CALL_POPOVER_WITH_KEYBOARD_CLOSED: 'callPopoverWithKeyboardClosed', + ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN: 'attachmentsPopoverWithKeyboardOpen', + ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED: 'attachmentsPopoverWithKeyboardClosed', + MODAL_DELETED: 'modalDeleted', + MODAL_WITH_KEYBOARD_OPEN_DELETED: 'modalWithKeyboardOpenDeleted', +}; + +const STATE_MACHINE = { + [States.IDLE]: { + [Actions.OPEN_POPOVER]: States.POPOVER_OPEN, + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + [Actions.MEASURE_POPOVER]: States.IDLE, + [Actions.MEASURE_COMPOSER]: States.IDLE, + [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, + [Actions.SHOW_ATTACHMENTS_PICKER_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, + }, + [States.POPOVER_OPEN]: { + [Actions.CLOSE_POPOVER]: States.POPOVER_CLOSED, + [Actions.MEASURE_POPOVER]: States.POPOVER_OPEN, + [Actions.MEASURE_COMPOSER]: States.POPOVER_OPEN, + [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, + [Actions.POPOVER_ANY_ACTION]: States.POPOVER_CLOSED, + [Actions.HIDE_WITHOUT_ANIMATION]: States.IDLE, + [Actions.EDIT_REPORT]: States.IDLE, + [Actions.SHOW_DELETE_CONFIRM_MODAL]: States.MODAL_DELETED, + }, + [States.POPOVER_CLOSED]: { + [Actions.END_TRANSITION]: States.IDLE, + }, + [States.EMOJI_PICKER_POPOVER_OPEN]: { + [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, + [Actions.CLOSE_EMOJI_PICKER_POPOVER]: States.POPOVER_CLOSED, + }, + [States.MODAL_DELETED]: { + [Actions.MEASURE_CONFIRM_MODAL]: States.MODAL_DELETED, + [Actions.CLOSE_CONFIRM_MODAL]: States.POPOVER_CLOSED, + }, + [States.KEYBOARD_OPEN]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + [Actions.OPEN_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.OPEN_EMOJI_PICKER_POPOVER_STANDALONE]: States.EMOJI_PICKER_WITH_KEYBOARD_OPEN, + [Actions.CLOSE_KEYBOARD]: States.IDLE, + [Actions.OPEN_CALL_POPOVER]: States.CALL_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.SHOW_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.SHOW_ATTACHMENTS_PICKER_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.MEASURE_COMPOSER]: States.KEYBOARD_OPEN, + }, + [States.KEYBOARD_POPOVER_OPEN]: { + [Actions.MEASURE_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.MEASURE_COMPOSER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.CLOSE_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, + [Actions.CLOSE_EMOJI_PICKER_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, + [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.KEYBOARD_POPOVER_OPEN, + [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.SHOW_DELETE_CONFIRM_MODAL]: States.MODAL_WITH_KEYBOARD_OPEN_DELETED, + }, + [States.MODAL_WITH_KEYBOARD_OPEN_DELETED]: { + [Actions.MEASURE_CONFIRM_MODAL]: States.MODAL_WITH_KEYBOARD_OPEN_DELETED, + [Actions.CLOSE_CONFIRM_MODAL]: States.KEYBOARD_CLOSED_POPOVER, + }, + [States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN]: { + [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.CLOSE_EMOJI_PICKER_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, + }, + [States.EMOJI_PICKER_WITH_KEYBOARD_OPEN]: { + [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_WITH_KEYBOARD_OPEN, + [Actions.CLOSE_EMOJI_PICKER_POPOVER_STANDALONE]: States.KEYBOARD_POPOVER_CLOSED, + }, + [States.CALL_POPOVER_WITH_KEYBOARD_OPEN]: { + [Actions.MEASURE_POPOVER]: States.CALL_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.MEASURE_CALL_POPOVER]: States.CALL_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.CLOSE_CALL_POPOVER]: States.CALL_POPOVER_WITH_KEYBOARD_CLOSED, + }, + [States.CALL_POPOVER_WITH_KEYBOARD_CLOSED]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + }, + [States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN]: { + [Actions.MEASURE_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.MEASURE_COMPOSER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED, + }, + [States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + }, + [States.KEYBOARD_POPOVER_CLOSED]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + }, + [States.KEYBOARD_CLOSED_POPOVER]: { + [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, + [Actions.END_TRANSITION]: States.KEYBOARD_OPEN, + }, +}; + +function ActionSheetAwareScrollViewProvider(props: PropsWithChildren) { + const {currentState, transition, transitionWorklet, reset} = useWorkletStateMachine(STATE_MACHINE, { + previous: { + state: 'idle', + payload: null, + }, + current: { + state: 'idle', + payload: null, + }, + }); + + const value = useMemo( + () => ({ + currentActionSheetState: currentState, + transitionActionSheetState: transition, + transitionActionSheetStateWorklet: transitionWorklet, + resetStateMachine: reset, + }), + [currentState, reset, transition, transitionWorklet], + ); + + return {props.children}; +} + +ActionSheetAwareScrollViewProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export {ActionSheetAwareScrollViewContext, ActionSheetAwareScrollViewProvider, Actions, States}; diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx new file mode 100644 index 000000000000..095cb2556077 --- /dev/null +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -0,0 +1,301 @@ +import React, {useContext, useEffect, useRef} from 'react'; +import type {ViewProps} from 'react-native'; +import {useKeyboardHandler} from 'react-native-keyboard-controller'; +import Reanimated, {interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSequence, withSpring, withTiming} from 'react-native-reanimated'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {Actions, ActionSheetAwareScrollViewContext, States} from './ActionSheetAwareScrollViewContext'; + +const KeyboardState = { + UNKNOWN: 0, + OPENING: 1, + OPEN: 2, + CLOSING: 3, + CLOSED: 4, +}; +const useAnimatedKeyboard = () => { + const state = useSharedValue(KeyboardState.UNKNOWN); + const height = useSharedValue(0); + const progress = useSharedValue(0); + const heightWhenOpened = useSharedValue(0); + + useKeyboardHandler( + { + onStart: (e) => { + 'worklet'; + + // save the last keyboard height + if (e.height === 0) { + heightWhenOpened.value = height.value; + } + + if (e.height > 0) { + state.value = KeyboardState.OPENING; + } else { + state.value = KeyboardState.CLOSING; + } + }, + onMove: (e) => { + 'worklet'; + + progress.value = e.progress; + height.value = e.height; + }, + onEnd: (e) => { + 'worklet'; + + if (e.height > 0) { + state.value = KeyboardState.OPEN; + } else { + state.value = KeyboardState.CLOSED; + } + + height.value = e.height; + progress.value = e.progress; + }, + }, + [], + ); + + return {state, height, heightWhenOpened, progress}; +}; +const setInitialValueAndRunAnimation = (value: number, animation: number) => { + 'worklet'; + + return withSequence(withTiming(value, {duration: 0}), animation); +}; + +const useSafeAreaPaddings = () => { + const StyleUtils = useStyleUtils(); + const insets = useSafeAreaInsets(); + const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets ?? undefined); + + return {top: paddingTop, bottom: paddingBottom}; +}; + +const config = { + mass: 3, + stiffness: 1000, + damping: 500, +}; + +function ActionSheetKeyboardSpace(props: ViewProps) { + const styles = useThemeStyles(); + const safeArea = useSafeAreaPaddings(); + const keyboard = useAnimatedKeyboard(); + + // similar to using `global` in worklet but it's just a local object + const syncLocalWorkletState = useRef({ + lastState: KeyboardState.UNKNOWN, + }).current; + const {windowHeight} = useWindowDimensions(); + const {currentActionSheetState, transitionActionSheetStateWorklet: transition, transitionActionSheetState, resetStateMachine} = useContext(ActionSheetAwareScrollViewContext); + + // Reset state machine when component unmounts + useEffect(() => () => resetStateMachine(), [resetStateMachine]); + + useAnimatedReaction( + () => keyboard.state.value, + (lastState) => { + if (lastState === syncLocalWorkletState.lastState) { + return; + } + + syncLocalWorkletState.lastState = lastState; + + if (lastState === KeyboardState.OPEN) { + runOnJS(transitionActionSheetState)({ + type: Actions.OPEN_KEYBOARD, + }); + } else if (lastState === KeyboardState.CLOSED) { + runOnJS(transitionActionSheetState)({ + type: Actions.CLOSE_KEYBOARD, + }); + } + }, + [], + ); + + const translateY = useDerivedValue(() => { + const {current, previous} = currentActionSheetState.value; + + // we don't need to run any additional logic + // it will always return 0 for idle state + if (current.state === States.IDLE) { + return withSpring(0, config); + } + + const keyboardHeight = keyboard.height.value === 0 ? 0 : keyboard.height.value - safeArea.bottom; + // sometimes we need to know the last keyboard height + const lastKeyboardHeight = keyboard.heightWhenOpened.value - safeArea.bottom; + + const {popoverHeight = 0, fy, height, composerHeight = 0} = current.payload ?? {}; + + const invertedKeyboardHeight = keyboard.state.value === KeyboardState.CLOSED ? lastKeyboardHeight : 0; + + let elementOffset = 0; + + if (fy !== undefined && height !== undefined && popoverHeight !== undefined) { + elementOffset = fy + safeArea.top + height - (windowHeight - popoverHeight); + } + + // when the sate is not idle we know for sure we have previous state + const previousPayload = previous.payload ?? {}; + + let previousElementOffset = 0; + + if (previousPayload.fy !== undefined && previousPayload.height !== undefined && previousPayload.popoverHeight !== undefined) { + previousElementOffset = previousPayload.fy + safeArea.top + previousPayload.height - (windowHeight - previousPayload.popoverHeight); + } + + // Depending on the current and sometimes previous state we can return + // either animation or just a value + switch (current.state) { + case States.KEYBOARD_OPEN: { + if (previous.state === States.KEYBOARD_CLOSED_POPOVER) { + return Math.max(keyboard.heightWhenOpened.value - keyboard.height.value - safeArea.bottom, 0) + Math.max(elementOffset, 0); + } + + return withSpring(0, config); + } + + case States.POPOVER_CLOSED: { + return withSpring(0, config, () => { + transition({ + type: Actions.END_TRANSITION, + }); + }); + } + + case States.MODAL_DELETED: + case States.EMOJI_PICKER_POPOVER_OPEN: + case States.POPOVER_OPEN: { + if (popoverHeight) { + if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { + return withSpring(elementOffset < 0 ? 0 : elementOffset, config); + } + + return withSpring(Math.max(previousElementOffset, 0), config); + } + + return 0; + } + + case States.MODAL_WITH_KEYBOARD_OPEN_DELETED: + case States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN: { + // when item is higher than keyboard and bottom sheet + // we should just stay in place + if (elementOffset < 0) { + return invertedKeyboardHeight; + } + + const nextOffset = invertedKeyboardHeight + elementOffset; + if (previous?.payload?.popoverHeight !== popoverHeight) { + const previousOffset = invertedKeyboardHeight + previousElementOffset; + + if (previousElementOffset === 0 || nextOffset > previousOffset) { + return withSpring(nextOffset, config); + } + + return previousOffset; + } + + return nextOffset; + } + + case States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED: + case States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN: { + return interpolate(keyboard.progress.value, [0, 1], [popoverHeight - composerHeight, 0]); + } + case States.CALL_POPOVER_WITH_KEYBOARD_OPEN: { + if (keyboard.height.value > 0) { + return 0; + } + + return setInitialValueAndRunAnimation(lastKeyboardHeight, withSpring(popoverHeight - composerHeight, config)); + } + case States.CALL_POPOVER_WITH_KEYBOARD_CLOSED: { + // keyboard is opened + if (keyboard.height.value > 0) { + return 0; + } + + return withSpring(lastKeyboardHeight, config); + } + case States.EMOJI_PICKER_WITH_KEYBOARD_OPEN: { + if (keyboard.state.value === KeyboardState.CLOSED) { + return popoverHeight - composerHeight; + } + + return 0; + } + + case States.KEYBOARD_POPOVER_CLOSED: { + if (keyboard.heightWhenOpened.value === keyboard.height.value) { + return 0; + } + + return popoverHeight - composerHeight; + } + + case States.KEYBOARD_POPOVER_OPEN: { + if (keyboard.state.value === KeyboardState.OPEN) { + return 0; + } + + const nextOffset = elementOffset + lastKeyboardHeight; + + if (keyboard.state.value === KeyboardState.CLOSED && nextOffset > invertedKeyboardHeight) { + return withSpring(nextOffset < 0 ? 0 : nextOffset, config); + } + + if (elementOffset < 0) { + return lastKeyboardHeight - keyboardHeight; + } + + return lastKeyboardHeight; + } + + case States.KEYBOARD_CLOSED_POPOVER: { + if (elementOffset < 0) { + transition({type: Actions.END_TRANSITION}); + + return 0; + } + + if (keyboard.state.value === KeyboardState.CLOSED) { + return elementOffset + lastKeyboardHeight; + } + + if (keyboard.height.value > 0) { + return keyboard.heightWhenOpened.value - keyboard.height.value + elementOffset; + } + + return withTiming(elementOffset + lastKeyboardHeight, { + duration: 0, + }); + } + + default: + return 0; + } + }, []); + + const animatedStyle = useAnimatedStyle(() => ({ + paddingTop: translateY.value, + })); + + return ( + + ); +} + +ActionSheetKeyboardSpace.displayName = 'ReportKeyboardSpace'; + +export default ActionSheetKeyboardSpace; diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.ios.tsx new file mode 100644 index 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/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx index 595e28acd3bc..53f9daf3e044 100644 --- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx +++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx @@ -41,7 +41,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow return ( - {({anchor, report, action, checkIfContextMenuActive}) => ( + {({onShowContextMenu, anchor, report, action, checkIfContextMenuActive}) => ( { @@ -53,7 +53,9 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow }} onPressIn={onPressIn} onPressOut={onPressOut} - onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onLongPress={(event) => + onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))) + } shouldUseHapticsOnLongPress accessibilityLabel={displayName} role={CONST.ROLE.BUTTON} diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index f6730f4b81d9..6824a8f59335 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -110,7 +110,7 @@ const getDataForUpload = (fileData: FileResponse): Promise => { * a callback. This is the ios/android implementation * opening a modal with attachment options */ -function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, shouldHideCameraOption = false}: AttachmentPickerProps) { +function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, shouldHideCameraOption = false, onLayout}: AttachmentPickerProps) { const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); @@ -347,6 +347,7 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s return ( <> { close(); onCanceled.current(); diff --git a/src/components/AttachmentPicker/types.ts b/src/components/AttachmentPicker/types.ts index 445d79bce07a..8f98275da1bd 100644 --- a/src/components/AttachmentPicker/types.ts +++ b/src/components/AttachmentPicker/types.ts @@ -1,4 +1,5 @@ import type {ReactNode} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; import type CONST from '@src/CONST'; @@ -40,6 +41,12 @@ type AttachmentPickerProps = { /** The types of files that can be selected with this picker. */ type?: ValueOf; + + /** + * Optional callback attached to popover's children container. + * Invoked on Popover mount and layout changes. + */ + onLayout?: ((event: LayoutChangeEvent) => void) | undefined; }; export default AttachmentPickerProps; diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index 26331f92401c..c0fb6d94018d 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -1,6 +1,6 @@ import type {ReactNode} from 'react'; -import React from 'react'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; +import React, {useCallback, useContext} from 'react'; +import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -11,6 +11,7 @@ import colors from '@styles/theme/colors'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; +import {Actions, ActionSheetAwareScrollViewContext} from './ActionSheetAwareScrollView'; import Button from './Button'; import Header from './Header'; import Icon from './Icon'; @@ -93,12 +94,27 @@ function ConfirmContent({ iconAdditionalStyles, image, }: ConfirmContentProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const {translate} = useLocalize(); const theme = useTheme(); const {isOffline} = useNetwork(); const StyleUtils = useStyleUtils(); + const onLayout = useCallback( + (event: LayoutChangeEvent) => { + const {height} = event.nativeEvent.layout; + + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: Actions.MEASURE_CONFIRM_MODAL, + payload: { + popoverHeight: height, + }, + }); + }, + [actionSheetAwareScrollViewContext], + ); + const isCentered = shouldCenterContent; return ( @@ -115,7 +131,10 @@ function ConfirmContent({ )} - + {typeof iconSource === 'function' && ( diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index 6e0944e5a913..d2e732728af9 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -1,5 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {memo, useEffect, useRef} from 'react'; +import React, {memo, useContext, useEffect, useRef} from 'react'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -30,12 +31,50 @@ type EmojiPickerButtonProps = { }; function EmojiPickerButton({isDisabled = false, id = '', emojiPickerID = '', shiftVertical = 0, 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 onPress = () => { + if (!isFocused) { + return; + } + + actionSheetContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.OPEN_EMOJI_PICKER_POPOVER_STANDALONE, + }); + + const onHide = () => { + actionSheetContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER_STANDALONE, + }); + + if (onModalHide) { + onModalHide(); + } + }; + + if (!EmojiPickerAction.emojiPickerRef.current?.isEmojiPickerVisible) { + EmojiPickerAction.showEmojiPicker( + onHide, + onEmojiSelected, + emojiPopoverAnchor, + { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + shiftVertical, + }, + () => {}, + emojiPickerID, + ); + } else { + EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); + } + }; + useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []); return ( @@ -44,27 +83,7 @@ function EmojiPickerButton({isDisabled = false, id = '', emojiPickerID = '', shi ref={emojiPopoverAnchor} style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]} disabled={isDisabled} - onPress={() => { - 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={onPress} id={id} accessibilityLabel={translate('reportActionCompose.emoji')} > diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index b5b4c2d7e71c..01826cd07163 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -1,9 +1,10 @@ import type {ListRenderItem} from '@shopify/flash-list'; import lodashDebounce from 'lodash/debounce'; -import React, {useCallback} from 'react'; +import React, {useCallback, useContext} from 'react'; import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import {runOnUI, scrollTo} from 'react-native-reanimated'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -114,9 +115,25 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r }, [styles, windowWidth, preferredSkinTone, singleExecution, onEmojiSelected, translate, activeEmoji], ); + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); + const onLayout = useCallback( + (event) => { + const {height} = event.nativeEvent.layout; + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.MEASURE_EMOJI_PICKER_POPOVER, + payload: { + popoverHeight: height, + }, + }); + }, + [actionSheetAwareScrollViewContext], + ); return ( - + - {({anchor, report, action, checkIfContextMenuActive}) => ( + {({onShowContextMenu, anchor, report, action, checkIfContextMenuActive}) => ( {({reportID, accountID, type}) => ( showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report)))} shouldUseHapticsOnLongPress accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={translate('accessibilityHints.viewAttachment')} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 504ddecb492b..95c4d7e636d7 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -83,10 +83,12 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona return ( - {({anchor, report, action, checkIfContextMenuActive}) => ( + {({onShowContextMenu, anchor, report, action, checkIfContextMenuActive}) => ( showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onLongPress={(event) => + onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))) + } onPress={(event) => { event.preventDefault(); Navigation.navigate(navigationRoute); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index 39a1993c2334..69e9fa0e0f80 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -34,12 +34,14 @@ function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...d return ( - {({anchor, report, action, checkIfContextMenuActive}) => ( + {({onShowContextMenu, anchor, report, action, checkIfContextMenuActive}) => ( {})} onPressIn={onPressIn} onPressOut={onPressOut} - onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onLongPress={(event) => + onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))) + } shouldUseHapticsOnLongPress role={CONST.ROLE.PRESENTATION} accessibilityLabel={translate('accessibilityHints.prestyledText')} diff --git a/src/components/KeyboardAvoidingView/index.ios.tsx b/src/components/KeyboardAvoidingView/index.ios.tsx index a7cd767377ef..485d230bbfd8 100644 --- a/src/components/KeyboardAvoidingView/index.ios.tsx +++ b/src/components/KeyboardAvoidingView/index.ios.tsx @@ -2,7 +2,7 @@ * The KeyboardAvoidingView is only used on ios */ import React from 'react'; -import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native'; +import {KeyboardAvoidingView as KeyboardAvoidingViewComponent} from 'react-native-keyboard-controller'; import type KeyboardAvoidingViewProps from './types'; function KeyboardAvoidingView(props: KeyboardAvoidingViewProps) { diff --git a/src/components/KeyboardHandlerProvider.tsx b/src/components/KeyboardHandlerProvider.tsx new file mode 100644 index 000000000000..dc208d10aeb3 --- /dev/null +++ b/src/components/KeyboardHandlerProvider.tsx @@ -0,0 +1,12 @@ +import type {PropsWithChildren} from 'react'; +import React from 'react'; +import {Platform} from 'react-native'; +import {KeyboardProvider} from 'react-native-keyboard-controller'; + +type Props = PropsWithChildren; + +function KeyboardHandlerProvider({children}: Props) { + return {children}; +} + +export default KeyboardHandlerProvider; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index e525d07fa1d3..f0f708d9deb0 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -1,5 +1,6 @@ import type {RefObject} from 'react'; import React, {useEffect, useRef, useState} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import type {ModalProps} from 'react-native-modal'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; @@ -36,6 +37,9 @@ type PopoverMenuProps = Partial & { /** Callback method fired when the user requests to close the modal */ onClose: () => void; + /** Optional callback passed to popover's children container */ + onLayout?: (e: LayoutChangeEvent) => void; + /** State that determines whether to display the modal or not */ isVisible: boolean; @@ -83,6 +87,7 @@ function PopoverMenu({ anchorPosition, anchorRef, onClose, + onLayout, headerText, fromSidebarMediumScreen, anchorAlignment = { @@ -198,7 +203,10 @@ function PopoverMenu({ shouldSetModalVisibility={shouldSetModalVisibility} shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} > - + {!!headerText && {headerText}} {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} {currentMenuItems.map((item, menuIndex) => ( diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx index 32cc589bf0fb..ecfb6c9d3ca7 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'; import type {WindowDimensionsProps} from './withWindowDimensions/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.value.current.payload?.popoverHeight !== Math.floor(popoverHeight)) { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.MEASURE_POPOVER, + payload: { + popoverHeight: Math.floor(popoverHeight), + }, + }); + } }; const adjustedAnchorPosition = useMemo(() => { diff --git a/src/components/Reactions/AddReactionBubble.tsx b/src/components/Reactions/AddReactionBubble.tsx index 8364a6658270..e7af464d61f5 100644 --- a/src/components/Reactions/AddReactionBubble.tsx +++ b/src/components/Reactions/AddReactionBubble.tsx @@ -57,9 +57,10 @@ function AddReactionBubble({onSelectEmoji, reportAction, onPressOpenPicker, onWi useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []); const onPress = () => { - const openPicker = (refParam?: PickerRefElement, anchorOrigin?: AnchorOrigin) => { + const openPicker = (refParam?: PickerRefElement, anchorOrigin?: AnchorOrigin, onHide = () => {}) => { EmojiPickerAction.showEmojiPicker( () => { + onHide(); setIsEmojiPickerActive?.(false); }, (emojiCode, emojiObject) => { diff --git a/src/components/Reactions/QuickEmojiReactions/index.native.tsx b/src/components/Reactions/QuickEmojiReactions/index.native.tsx index b0eb88b31b68..6c55beb9741d 100644 --- a/src/components/Reactions/QuickEmojiReactions/index.native.tsx +++ b/src/components/Reactions/QuickEmojiReactions/index.native.tsx @@ -1,10 +1,17 @@ -import React from 'react'; +import React, {useContext} from 'react'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import BaseQuickEmojiReactions from './BaseQuickEmojiReactions'; -import type {OpenPickerCallback, QuickEmojiReactionsProps} from './types'; +import type {BaseQuickEmojiReactionsProps, OpenPickerCallback, QuickEmojiReactionsProps} from './types'; + +function QuickEmojiReactions({closeContextMenu, onEmojiSelected, ...rest}: QuickEmojiReactionsProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); -function QuickEmojiReactions({closeContextMenu, ...rest}: QuickEmojiReactionsProps) { const onPressOpenPicker = (openPicker?: OpenPickerCallback) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.OPEN_EMOJI_PICKER_POPOVER, + }); + // We first need to close the menu as it's a popover. // The picker is a popover as well and on mobile there can only // be one active popover at a time. @@ -13,13 +20,28 @@ function QuickEmojiReactions({closeContextMenu, ...rest}: QuickEmojiReactionsPro // gets closed, before the picker actually opens, we pass the composer // ref as anchor for the emoji picker popover. openPicker?.(ReportActionComposeFocusManager.composerRef); + + openPicker?.(ReportActionComposeFocusManager.composerRef, undefined, () => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER, + }); + }); }); }; + const onEmojiSelectedCallback: BaseQuickEmojiReactionsProps['onEmojiSelected'] = (emoji, emojiReactions) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER, + }); + + onEmojiSelected(emoji, emojiReactions); + }; + return ( ); diff --git a/src/components/Reactions/QuickEmojiReactions/types.ts b/src/components/Reactions/QuickEmojiReactions/types.ts index 0021f33ce2c0..725b5aea764f 100644 --- a/src/components/Reactions/QuickEmojiReactions/types.ts +++ b/src/components/Reactions/QuickEmojiReactions/types.ts @@ -7,7 +7,7 @@ import type {Locale, ReportAction, ReportActionReactions} from '@src/types/onyx' type PickerRefElement = RefObject; -type OpenPickerCallback = (element?: PickerRefElement, anchorOrigin?: AnchorOrigin) => void; +type OpenPickerCallback = (element?: PickerRefElement, anchorOrigin?: AnchorOrigin, callback?: () => void) => void; type CloseContextMenuCallback = () => void; diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.tsx b/src/components/Reactions/ReportActionItemEmojiReactions.tsx index c6bf4f9e4016..f932c55b97ce 100644 --- a/src/components/Reactions/ReportActionItemEmojiReactions.tsx +++ b/src/components/Reactions/ReportActionItemEmojiReactions.tsx @@ -16,6 +16,7 @@ import type {Locale, ReportAction, ReportActionReactions} from '@src/types/onyx' import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import AddReactionBubble from './AddReactionBubble'; import EmojiReactionBubble from './EmojiReactionBubble'; +import type {OpenPickerCallback} from './QuickEmojiReactions/types'; import ReactionTooltipContent from './ReactionTooltipContent'; type ReportActionItemEmojiReactionsProps = WithCurrentUserPersonalDetailsProps & { @@ -35,6 +36,15 @@ type ReportActionItemEmojiReactionsProps = WithCurrentUserPersonalDetailsProps & */ toggleReaction: (emoji: Emoji) => void; + /** + * Function to call when the user presses on the add reaction button. + * This is only called when the user presses on the button, not on the + * reaction bubbles. + * This is optional, because we don't need it everywhere. + * For example in the ReportActionContextMenu we don't need it. + */ + onPressOpenPicker: (openPicker: OpenPickerCallback) => void; + /** We disable reacting with emojis on report actions that have errors */ shouldBlockReactions?: boolean; @@ -79,6 +89,7 @@ function ReportActionItemEmojiReactions({ reportAction, currentUserPersonalDetails, toggleReaction, + onPressOpenPicker, emojiReactions = {}, shouldBlockReactions = false, preferredLocale = CONST.LOCALES.DEFAULT, @@ -170,6 +181,7 @@ function ReportActionItemEmojiReactions({ })} {!shouldBlockReactions && ( void; + /** Callback for measuring child and running a defined callback/action later */ + onShowContextMenu?: (callback: () => void) => void; + /** Whether the IOU is hovered so we can modify its style */ isHovered?: boolean; @@ -69,6 +72,7 @@ function MoneyRequestAction({ reportID, isMostRecentIOUReportAction, contextMenuAnchor, + onShowContextMenu = () => {}, checkIfContextMenuActive = () => {}, chatReport, iouReport, @@ -128,6 +132,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 3331572ab625..ed7df3c4de1e 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -56,6 +56,7 @@ function MoneyRequestPreviewContent({ containerStyles, walletTerms, checkIfContextMenuActive = () => {}, + onShowContextMenu = () => {}, shouldShowPendingConversionMessage = false, isHovered = false, isWhisper = false, @@ -137,7 +138,7 @@ function MoneyRequestPreviewContent({ }; const showContextMenu = (event: GestureResponderEvent) => { - showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive); + onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, reportID, action, checkIfContextMenuActive)); }; const getPreviewHeaderText = (): string => { diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts index 9dcea80fdc05..694f229a8388 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts @@ -51,6 +51,9 @@ type MoneyRequestPreviewProps = MoneyRequestPreviewOnyxProps & { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive?: () => void; + /** Callback for measuring child and running a defined callback/action later */ + onShowContextMenu?: (callback: () => void) => void; + /** Extra styles to pass to View wrapper */ containerStyles?: StyleProp; diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index be3b104018db..deb8aead451c 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -78,6 +78,9 @@ type ReportPreviewProps = ReportPreviewOnyxProps & { /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive?: () => void; + /** Callback for measuring child and running a defined callback/action later */ + onShowContextMenu: (callback: () => void) => void; + /** Whether a message is a whisper */ isWhisper?: boolean; @@ -101,6 +104,7 @@ function ReportPreview({ isWhisper = false, checkIfContextMenuActive = () => {}, userWallet, + onShowContextMenu = () => {}, }: ReportPreviewProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -293,7 +297,7 @@ function ReportPreview({ }} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} + onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive))} shouldUseHapticsOnLongPress style={[styles.flexRow, styles.justifyContentBetween, styles.reportPreviewBox]} role="button" diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index 8e8b3b930be7..33256116cae7 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -58,9 +58,22 @@ type TaskPreviewProps = WithCurrentUserPersonalDetailsProps & /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: () => void; + + /** Callback that will do measure of necessary layout elements and run provided callback */ + onShowContextMenu: (callback: () => void) => void; }; -function TaskPreview({taskReport, taskReportID, action, contextMenuAnchor, chatReportID, checkIfContextMenuActive, currentUserPersonalDetails, isHovered = false}: TaskPreviewProps) { +function TaskPreview({ + taskReport, + taskReportID, + action, + contextMenuAnchor, + chatReportID, + checkIfContextMenuActive, + currentUserPersonalDetails, + onShowContextMenu, + isHovered = false, +}: TaskPreviewProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -87,7 +100,7 @@ function TaskPreview({taskReport, taskReportID, action, contextMenuAnchor, chatR onPress={() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(taskReportID))} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} + onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive))} shouldUseHapticsOnLongPress style={[styles.flexRow, styles.justifyContentBetween]} role={CONST.ROLE.BUTTON} diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts index 3a996a8d2c64..e0ab5a215f17 100644 --- a/src/components/ShowContextMenuContext.ts +++ b/src/components/ShowContextMenuContext.ts @@ -15,10 +15,12 @@ type ShowContextMenuContextProps = { action: OnyxEntry; transactionThreadReport: OnyxEntry; checkIfContextMenuActive: () => void; + onShowContextMenu: (callback: () => void) => void; }; const ShowContextMenuContext = createContext({ anchor: null, + onShowContextMenu: (callback) => callback(), report: null, action: null, transactionThreadReport: null, @@ -58,7 +60,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 7c3358e4688c..2e45d9a741df 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -1,7 +1,8 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import {Actions, ActionSheetAwareScrollViewContext} from '@components/ActionSheetAwareScrollView'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PopoverMenu from '@components/PopoverMenu'; @@ -38,6 +39,7 @@ function ThreeDotsMenu({ disabled = false, modal = {}, }: ThreeDotsMenuProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollViewContext); const theme = useTheme(); const styles = useThemeStyles(); const [isPopupMenuVisible, setPopupMenuVisible] = useState(false); @@ -46,19 +48,25 @@ function ThreeDotsMenu({ const isBehindModal = modal?.willAlertModalBecomeVisible && !modal?.isPopover && !shouldOverlay; const showPopoverMenu = () => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: Actions.OPEN_CALL_POPOVER, + }); setPopupMenuVisible(true); }; - const hidePopoverMenu = () => { + const hidePopoverMenu = useCallback(() => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: Actions.CLOSE_CALL_POPOVER, + }); setPopupMenuVisible(false); - }; + }, [actionSheetAwareScrollViewContext]); useEffect(() => { if (!isBehindModal || !isPopupMenuVisible) { return; } hidePopoverMenu(); - }, [isBehindModal, isPopupMenuVisible]); + }, [hidePopoverMenu, isBehindModal, isPopupMenuVisible]); return ( <> diff --git a/src/hooks/useWorkletStateMachine.ts b/src/hooks/useWorkletStateMachine.ts new file mode 100644 index 000000000000..5b09dc22b059 --- /dev/null +++ b/src/hooks/useWorkletStateMachine.ts @@ -0,0 +1,170 @@ +import {useCallback} from 'react'; +import {runOnJS, runOnUI, useSharedValue} from 'react-native-reanimated'; +import Log from '@libs/Log'; + +// When you need to debug state machine change this to true +const DEBUG_MODE = false; + +type Payload = Record; +type ActionWithPayload

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

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

= { + previous: StateHolder

; + current: StateHolder

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

(stateMachine: StateMachine, initialState: State

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

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

) => { + runOnUI(transitionWorklet)(action); + }, + [transitionWorklet], + ); + + return { + currentState, + transitionWorklet, + transition, + reset, + resetWorklet, + }; +} + +export type {ActionWithPayload, State}; +export default useWorkletStateMachine; diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 46ebdd751762..c6858b57b868 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -1,11 +1,12 @@ import lodashIsEqual from 'lodash/isEqual'; import type {MutableRefObject, RefObject} from 'react'; -import React, {memo, useMemo, useRef, useState} from 'react'; +import React, {memo, useContext, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Text as RNText, View as ViewType} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import type {ContextMenuItemHandle} from '@components/ContextMenuItem'; import ContextMenuItem from '@components/ContextMenuItem'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; @@ -118,6 +119,7 @@ function BaseReportActionContextMenu({ disabledActions = [], setIsEmojiPickerActive, }: BaseReportActionContextMenuProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -241,6 +243,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 efcd08c35a00..95cbf5845ab4 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -7,6 +7,7 @@ import {InteractionManager} from 'react-native'; import type {GestureResponderEvent, Text, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import * as Expensicons from '@components/Icon/Expensicons'; import MiniQuickEmojiReactions from '@components/Reactions/MiniQuickEmojiReactions'; import QuickEmojiReactions from '@components/Reactions/QuickEmojiReactions'; @@ -30,8 +31,8 @@ import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type {Beta, ReportAction, ReportActionReactions, Transaction} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; +import {clearActiveReportAction, hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; import type {ContextMenuAnchor} from './ReportActionContextMenu'; -import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; /** Gets the HTML version of the message in an action */ function getActionHtml(reportAction: OnyxEntry): string { @@ -73,6 +74,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; @@ -229,7 +231,7 @@ const ContextMenuActions: ContextMenuAction[] = [ icon: Expensicons.Pencil, shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && ReportUtils.canEditReportAction(reportAction) && !isArchivedRoom && !isChronosReport, - onPress: (closePopover, {reportID, reportAction, draftMessage}) => { + onPress: (closePopover, {reportID, reportAction, draftMessage, transitionActionSheetState}) => { if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { hideContextMenu(false); const childReportID = reportAction?.childReportID ?? '0'; @@ -247,6 +249,10 @@ const ContextMenuActions: ContextMenuAction[] = [ }; if (closePopover) { + transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.EDIT_REPORT, + }); + // Hide popover, then call editAction hideContextMenu(false, editAction); return; @@ -490,10 +496,21 @@ const ContextMenuActions: ContextMenuAction[] = [ !isArchivedRoom && !isChronosReport && !ReportActionsUtils.isMessageDeleted(reportAction), - onPress: (closePopover, {reportID, reportAction}) => { + onPress: (closePopover, {reportID, reportAction, transitionActionSheetState}) => { if (closePopover) { + transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.SHOW_DELETE_CONFIRM_MODAL, + }); + + const onClose = () => { + transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_CONFIRM_MODAL, + }); + clearActiveReportAction(); + }; + // Hide popover, then call showDeleteConfirmModal - hideContextMenu(false, () => showDeleteModal(reportID, reportAction)); + hideContextMenu(false, () => showDeleteModal(reportID, reportAction, true, onClose, onClose)); return; } diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 26aefe24bb20..7d2402813193 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -1,8 +1,10 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import type {FileObject} from '@components/AttachmentModal'; import AttachmentPicker from '@components/AttachmentPicker'; import Icon from '@components/Icon'; @@ -112,6 +114,7 @@ function AttachmentPickerWithMenuItems({ actionButtonRef, raiseIsScrollLikelyLayoutTriggered, }: AttachmentPickerWithMenuItemsProps) { + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const isFocused = useIsFocused(); const theme = useTheme(); const styles = useThemeStyles(); @@ -175,6 +178,18 @@ function AttachmentPickerWithMenuItems({ ]; }, [report, reportID, translate]); + const measurePopover = useCallback( + ({nativeEvent}: LayoutChangeEvent) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.MEASURE_POPOVER, + payload: { + popoverHeight: nativeEvent.layout.height, + }, + }); + }, + [actionSheetAwareScrollViewContext], + ); + const onPopoverMenuClose = () => { setMenuVisibility(false); onMenuClosed(); @@ -296,6 +311,7 @@ function AttachmentPickerWithMenuItems({ { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: isMenuVisible ? ActionSheetAwareScrollView.Actions.SHOW_ATTACHMENTS_POPOVER : ActionSheetAwareScrollView.Actions.CLOSE_ATTACHMENTS_POPOVER, + }); + }, [actionSheetAwareScrollViewContext, isMenuVisible]); + // When we invite someone to a room they don't have the policy object, but we still want them to be able to mention other reports they are members of, so we only check if the policyID in the report is from a workspace const isGroupPolicyReport = useMemo(() => !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE, [report]); const reportRecipientAcountIDs = ReportUtils.getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); @@ -371,6 +379,18 @@ function ReportActionCompose({ runOnJS(submitForm)(); }, [isSendDisabled, resetFullComposerSize, submitForm, animatedRef, isReportReadyForDisplay]); + const measureComposer = useCallback( + (e: LayoutChangeEvent) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.MEASURE_COMPOSER, + payload: { + composerHeight: e.nativeEvent.layout.height, + }, + }); + }, + [actionSheetAwareScrollViewContext], + ); + const emojiShiftVertical = useMemo(() => { const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; @@ -383,7 +403,10 @@ function ReportActionCompose({ {shouldShowReportRecipientLocalTime && hasReportRecipient && } - + { setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(action.reportActionID)); - }, [action.reportActionID]); + + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_POPOVER, + }); + }, [actionSheetAwareScrollViewContext, action.reportActionID]); + + const handlePressOpenPicker = useCallback( + (openPicker: OpenPickerCallback) => { + if (!(popoverAnchorRef.current && 'measureInWindow' in popoverAnchorRef.current)) { + return; + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + popoverAnchorRef.current.measureInWindow((_fx, fy, _width, height) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.OPEN_EMOJI_PICKER_POPOVER, + payload: { + fy, + height, + }, + }); + + openPicker(undefined, undefined, () => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER, + }); + }); + }); + }, + [actionSheetAwareScrollViewContext], + ); + + const handleShowContextMenu = useCallback( + (callback: () => void) => { + if (!(popoverAnchorRef.current && 'measureInWindow' in popoverAnchorRef.current)) { + return; + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + popoverAnchorRef.current?.measureInWindow((_fx, fy, _width, height) => { + actionSheetAwareScrollViewContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.OPEN_POPOVER, + payload: { + popoverHeight: 0, + fy, + height, + }, + }); + + callback(); + }); + }, + [actionSheetAwareScrollViewContext], + ); /** * Show the ReportActionContextMenu modal popover. @@ -335,29 +391,31 @@ function ReportActionItem({ return; } - setIsContextMenuActive(true); - const selection = SelectionScraper.getCurrentSelection(); - ReportActionContextMenu.showContextMenu( - CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - selection, - popoverAnchorRef.current, - report.reportID, - action.reportActionID, - originalReportID, - draftMessage ?? '', - () => setIsContextMenuActive(true), - toggleContextMenuFromActiveReportAction, - ReportUtils.isArchivedRoom(originalReport), - ReportUtils.chatIncludesChronos(originalReport), - false, - false, - [], - false, - setIsEmojiPickerActive as () => void, - ); + handleShowContextMenu(() => { + setIsContextMenuActive(true); + const selection = SelectionScraper.getCurrentSelection(); + ReportActionContextMenu.showContextMenu( + CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + event, + selection, + popoverAnchorRef.current, + report.reportID, + action.reportActionID, + originalReportID, + draftMessage ?? '', + () => setIsContextMenuActive(true), + toggleContextMenuFromActiveReportAction, + ReportUtils.isArchivedRoom(originalReport), + ReportUtils.chatIncludesChronos(originalReport), + false, + false, + [], + false, + setIsEmojiPickerActive as () => void, + ); + }); }, - [draftMessage, action, report.reportID, toggleContextMenuFromActiveReportAction, originalReport, originalReportID], + [draftMessage, action, report.reportID, toggleContextMenuFromActiveReportAction, originalReport, originalReportID, handleShowContextMenu], ); // Handles manual scrolling to the bottom of the chat when the last message is an actionable whisper and it's resolved. @@ -386,8 +444,9 @@ function ReportActionItem({ action, transactionThreadReport, checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, + onShowContextMenu: handleShowContextMenu, }), - [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport], + [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport, handleShowContextMenu], ); const attachmentContextValue = useMemo(() => ({reportID: report.reportID, type: CONST.ATTACHMENT_TYPE.REPORT}), [report.reportID]); @@ -530,6 +589,7 @@ function ReportActionItem({ isMostRecentIOUReportAction={isMostRecentIOUReportAction} isHovered={hovered} contextMenuAnchor={popoverAnchorRef.current} + onShowContextMenu={handleShowContextMenu} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} style={displayAsGroup ? [] : [styles.mt2]} isWhisper={isWhisper} @@ -546,6 +606,7 @@ function ReportActionItem({ containerStyles={displayAsGroup ? [] : [styles.mt2]} action={action} isHovered={hovered} + onShowContextMenu={handleShowContextMenu} contextMenuAnchor={popoverAnchorRef.current} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} isWhisper={isWhisper} @@ -561,6 +622,7 @@ function ReportActionItem({ chatReportID={report.reportID} action={action} isHovered={hovered} + onShowContextMenu={handleShowContextMenu} contextMenuAnchor={popoverAnchorRef.current} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} policyID={report.policyID ?? ''} @@ -707,6 +769,7 @@ function ReportActionItem({ {!ReportActionsUtils.isMessageDeleted(action) && ( Date: Fri, 15 Mar 2024 15:50:17 +0100 Subject: [PATCH 002/593] chore: fixed after code review --- .../ActionSheetAwareScrollViewContext.tsx | 2 ++ .../ActionSheetKeyboardSpace.tsx | 17 +++++++---------- .../EmojiPicker/EmojiPickerButton.tsx | 4 ++-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx index a8dda4adb621..59cf826bfa5b 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -12,12 +12,14 @@ type MeasuredElements = { height?: number; composerHeight?: number; }; + type Context = { currentActionSheetState: SharedValue>; transitionActionSheetState: (action: ActionWithPayload) => void; transitionActionSheetStateWorklet: (action: ActionWithPayload) => void; resetStateMachine: () => void; }; + const defaultValue: Context = { currentActionSheetState: { value: { diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index 095cb2556077..75ebf396e155 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -15,6 +15,7 @@ const KeyboardState = { CLOSING: 3, CLOSED: 4, }; + const useAnimatedKeyboard = () => { const state = useSharedValue(KeyboardState.UNKNOWN); const height = useSharedValue(0); @@ -61,6 +62,7 @@ const useAnimatedKeyboard = () => { return {state, height, heightWhenOpened, progress}; }; + const setInitialValueAndRunAnimation = (value: number, animation: number) => { 'worklet'; @@ -135,20 +137,15 @@ function ActionSheetKeyboardSpace(props: ViewProps) { const invertedKeyboardHeight = keyboard.state.value === KeyboardState.CLOSED ? lastKeyboardHeight : 0; - let elementOffset = 0; - - if (fy !== undefined && height !== undefined && popoverHeight !== undefined) { - elementOffset = fy + safeArea.top + height - (windowHeight - popoverHeight); - } + const elementOffset = fy !== undefined && height !== undefined && popoverHeight !== undefined ? fy + safeArea.top + height - (windowHeight - popoverHeight) : 0; // when the sate is not idle we know for sure we have previous state const previousPayload = previous.payload ?? {}; - let previousElementOffset = 0; - - if (previousPayload.fy !== undefined && previousPayload.height !== undefined && previousPayload.popoverHeight !== undefined) { - previousElementOffset = previousPayload.fy + safeArea.top + previousPayload.height - (windowHeight - previousPayload.popoverHeight); - } + const previousElementOffset = + previousPayload.fy !== undefined && previousPayload.height !== undefined && previousPayload.popoverHeight !== undefined + ? previousPayload.fy + safeArea.top + previousPayload.height - (windowHeight - previousPayload.popoverHeight) + : 0; // Depending on the current and sometimes previous state we can return // either animation or just a value diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index d2e732728af9..40e3cdd45f71 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -38,7 +38,7 @@ function EmojiPickerButton({isDisabled = false, id = '', emojiPickerID = '', shi const {translate} = useLocalize(); const isFocused = useIsFocused(); - const onPress = () => { + const openEmojiPicker = () => { if (!isFocused) { return; } @@ -83,7 +83,7 @@ function EmojiPickerButton({isDisabled = false, id = '', emojiPickerID = '', shi ref={emojiPopoverAnchor} style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]} disabled={isDisabled} - onPress={onPress} + onPress={openEmojiPicker} id={id} accessibilityLabel={translate('reportActionCompose.emoji')} > From 30d22dcb7ba4972cf1b0ab3181eaf16c8cfd479d Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Mon, 18 Mar 2024 13:32:52 +0100 Subject: [PATCH 003/593] fix: remove resetWorklet since it's not used right now --- src/hooks/useWorkletStateMachine.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useWorkletStateMachine.ts b/src/hooks/useWorkletStateMachine.ts index 5b09dc22b059..f0c3fde0012d 100644 --- a/src/hooks/useWorkletStateMachine.ts +++ b/src/hooks/useWorkletStateMachine.ts @@ -162,7 +162,6 @@ function useWorkletStateMachine

(stateMachine: StateMachine, initialState: Sta transitionWorklet, transition, reset, - resetWorklet, }; } From a6eebcce3f3b480fd53174453cc3f660ee9352ab Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 19 Mar 2024 16:31:00 +0100 Subject: [PATCH 004/593] fix: frozen KeyboardAvoidingView when it was mounted with an opened keyboard --- .../react-native-keyboard-controller+1.10.4.patch | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 patches/react-native-keyboard-controller+1.10.4.patch diff --git a/patches/react-native-keyboard-controller+1.10.4.patch b/patches/react-native-keyboard-controller+1.10.4.patch new file mode 100644 index 000000000000..ab2d23fcd093 --- /dev/null +++ b/patches/react-native-keyboard-controller+1.10.4.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/index.tsx b/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/index.tsx +index 9e73caf..0d5f143 100644 +--- a/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/index.tsx ++++ b/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/index.tsx +@@ -81,7 +81,7 @@ const KeyboardAvoidingView = forwardRef>( + const onLayoutWorklet = useCallback((layout: LayoutRectangle) => { + "worklet"; + +- if (keyboard.isClosed.value) { ++ if (keyboard.isClosed.value || initialFrame.value === null) { + initialFrame.value = layout; + } + }, []); From 8b0b29754a7ec780b697954de20b5a2268c835f3 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 21 Mar 2024 12:42:05 +0100 Subject: [PATCH 005/593] chore: update keyboard-controller --- package-lock.json | 8 ++++---- package.json | 2 +- .../react-native-keyboard-controller+1.10.4.patch | 13 ------------- 3 files changed, 5 insertions(+), 18 deletions(-) delete mode 100644 patches/react-native-keyboard-controller+1.10.4.patch diff --git a/package-lock.json b/package-lock.json index d3bcfea84c51..d42a93b9496a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,7 +98,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.10.4", + "react-native-keyboard-controller": "^1.11.4", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", @@ -31421,9 +31421,9 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.10.4.tgz", - "integrity": "sha512-PQ3AcKGnacDBeA1zB1y44XLgj0sZd3Py5Kpml412bKgYiM09JgoK7YbJcUxMayTeEGtZ8GTOteevGTbGq1Otrg==", + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.11.4.tgz", + "integrity": "sha512-Gn/3d7dut8IyCZikvywKivwrYFfpj3YFaT8YcxtTL46jeoG61qFIjzEV2OPdRy476c6Mea3rByhrdiR30XCfVg==", "peerDependencies": { "react": "*", "react-native": "*", diff --git a/package.json b/package.json index efadc759b85f..72ca02f36d82 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.10.4", + "react-native-keyboard-controller": "^1.11.4", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", diff --git a/patches/react-native-keyboard-controller+1.10.4.patch b/patches/react-native-keyboard-controller+1.10.4.patch deleted file mode 100644 index ab2d23fcd093..000000000000 --- a/patches/react-native-keyboard-controller+1.10.4.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/index.tsx b/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/index.tsx -index 9e73caf..0d5f143 100644 ---- a/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/index.tsx -+++ b/node_modules/react-native-keyboard-controller/src/components/KeyboardAvoidingView/index.tsx -@@ -81,7 +81,7 @@ const KeyboardAvoidingView = forwardRef>( - const onLayoutWorklet = useCallback((layout: LayoutRectangle) => { - "worklet"; - -- if (keyboard.isClosed.value) { -+ if (keyboard.isClosed.value || initialFrame.value === null) { - initialFrame.value = layout; - } - }, []); From 7de98477e75ee0c634ef46fbc8067aa00e1d5ef0 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 21 Mar 2024 13:24:11 +0100 Subject: [PATCH 006/593] fix: composer under keyboard when you press Edit message --- .../ActionSheetAwareScrollViewContext.tsx | 5 +++++ .../ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx index 59cf826bfa5b..f592146659f4 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -90,6 +90,7 @@ const States = { ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED: 'attachmentsPopoverWithKeyboardClosed', MODAL_DELETED: 'modalDeleted', MODAL_WITH_KEYBOARD_OPEN_DELETED: 'modalWithKeyboardOpenDeleted', + EDIT_MESSAGE: 'editMessage', }; const STATE_MACHINE = { @@ -141,6 +142,7 @@ const STATE_MACHINE = { [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.KEYBOARD_POPOVER_OPEN, [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN, [Actions.SHOW_DELETE_CONFIRM_MODAL]: States.MODAL_WITH_KEYBOARD_OPEN_DELETED, + [Actions.EDIT_REPORT]: States.EDIT_MESSAGE, }, [States.MODAL_WITH_KEYBOARD_OPEN_DELETED]: { [Actions.MEASURE_CONFIRM_MODAL]: States.MODAL_WITH_KEYBOARD_OPEN_DELETED, @@ -177,6 +179,9 @@ const STATE_MACHINE = { [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, [Actions.END_TRANSITION]: States.KEYBOARD_OPEN, }, + [States.EDIT_MESSAGE]: { + [Actions.CLOSE_KEYBOARD]: States.IDLE, + }, }; function ActionSheetAwareScrollViewProvider(props: PropsWithChildren) { diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index 75ebf396e155..cfef99c87d08 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -274,6 +274,9 @@ function ActionSheetKeyboardSpace(props: ViewProps) { duration: 0, }); } + case States.EDIT_MESSAGE: { + return 0; + } default: return 0; From d7052062d1cd539a5530d8143c7110c6a9638629 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Wed, 27 Mar 2024 13:17:54 +0100 Subject: [PATCH 007/593] chore: use latest RNKC version --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d42a93b9496a..d671b07f21e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,7 +98,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.11.4", + "react-native-keyboard-controller": "^1.11.5", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", @@ -31421,9 +31421,9 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.11.4", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.11.4.tgz", - "integrity": "sha512-Gn/3d7dut8IyCZikvywKivwrYFfpj3YFaT8YcxtTL46jeoG61qFIjzEV2OPdRy476c6Mea3rByhrdiR30XCfVg==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.11.5.tgz", + "integrity": "sha512-AtlJVz+Sm9uh5Y+k0AhqJDVnbpYkieOE+qHwO8046Fyt9blxK5pGprOQLNy9hcJjfxwhiwiSFk9VyZlZfaYe6Q==", "peerDependencies": { "react": "*", "react-native": "*", diff --git a/package.json b/package.json index 72ca02f36d82..37c2a0af4e35 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.11.4", + "react-native-keyboard-controller": "^1.11.5", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", From f0ed018c225f4fc1222f60849930a1e66a1e0a7a Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 14 May 2024 13:26:32 +0200 Subject: [PATCH 008/593] post rebase changes --- ios/Podfile.lock | 20 +++++++++++++++++++ package-lock.json | 8 ++++---- package.json | 2 +- .../EmojiPickerMenu/index.native.tsx | 3 ++- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1cac95e15036..23558b1a9b7a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1303,6 +1303,25 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-keyboard-controller (1.12.0): + - glog + - hermes-engine + - RCT-Folly (= 2022.05.16.00) + - RCTRequired + - RCTTypeSafety + - React-Codegen + - React-Core + - React-debug + - React-Fabric + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-launch-arguments (4.0.2): - React - react-native-netinfo (11.2.1): @@ -2544,6 +2563,7 @@ SPEC CHECKSUMS: react-native-geolocation: f9e92eb774cb30ac1e099f34b3a94f03b4db7eb3 react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440 react-native-key-command: 28ccfa09520e7d7e30739480dea4df003493bfe8 + react-native-keyboard-controller: 967a185a802fbc07a8ff4562d246158908bdf590 react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: 02d31de0e08ab043d48f2a1a8baade109d7b6ca5 react-native-pager-view: ccd4bbf9fc7effaf8f91f8dae43389844d9ef9fa diff --git a/package-lock.json b/package-lock.json index d671b07f21e7..434fc0752fca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,7 +98,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.11.5", + "react-native-keyboard-controller": "^1.12.0", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", @@ -31421,9 +31421,9 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.11.5.tgz", - "integrity": "sha512-AtlJVz+Sm9uh5Y+k0AhqJDVnbpYkieOE+qHwO8046Fyt9blxK5pGprOQLNy9hcJjfxwhiwiSFk9VyZlZfaYe6Q==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.0.tgz", + "integrity": "sha512-am/1qIClurdUEgCD43E5/YJEO62XrBJLnvq3dFJt6KJAMN9/f3Ffz04tbQ5xEY5k91uurJX4Ev8bEXXJfaIvaA==", "peerDependencies": { "react": "*", "react-native": "*", diff --git a/package.json b/package.json index 37c2a0af4e35..3dd91101f02f 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.11.5", + "react-native-keyboard-controller": "^1.12.0", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index 01826cd07163..41fac94e9e7d 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -2,6 +2,7 @@ import type {ListRenderItem} from '@shopify/flash-list'; import lodashDebounce from 'lodash/debounce'; import React, {useCallback, useContext} from 'react'; import type {ForwardedRef} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import {runOnUI, scrollTo} from 'react-native-reanimated'; import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; @@ -117,7 +118,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r ); const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const onLayout = useCallback( - (event) => { + (event: LayoutChangeEvent) => { const {height} = event.nativeEvent.layout; actionSheetAwareScrollViewContext.transitionActionSheetState({ type: ActionSheetAwareScrollView.Actions.MEASURE_EMOJI_PICKER_POPOVER, From 5fe5b3466675f051d66cd509234b2f4199677dee Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 16 May 2024 11:36:55 +0200 Subject: [PATCH 009/593] feat: update keyboard-controller to avoid crashes --- ios/Podfile.lock | 4 ++-- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 23558b1a9b7a..97397b5e213a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1303,7 +1303,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-keyboard-controller (1.12.0): + - react-native-keyboard-controller (1.12.1): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -2563,7 +2563,7 @@ SPEC CHECKSUMS: react-native-geolocation: f9e92eb774cb30ac1e099f34b3a94f03b4db7eb3 react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440 react-native-key-command: 28ccfa09520e7d7e30739480dea4df003493bfe8 - react-native-keyboard-controller: 967a185a802fbc07a8ff4562d246158908bdf590 + react-native-keyboard-controller: 36bc3176750b519d746aa5f328fdac7ec2e82bb2 react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: 02d31de0e08ab043d48f2a1a8baade109d7b6ca5 react-native-pager-view: ccd4bbf9fc7effaf8f91f8dae43389844d9ef9fa diff --git a/package-lock.json b/package-lock.json index 434fc0752fca..562a433c14f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,7 +98,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.12.0", + "react-native-keyboard-controller": "^1.12.1", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", @@ -31421,9 +31421,9 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.0.tgz", - "integrity": "sha512-am/1qIClurdUEgCD43E5/YJEO62XrBJLnvq3dFJt6KJAMN9/f3Ffz04tbQ5xEY5k91uurJX4Ev8bEXXJfaIvaA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.1.tgz", + "integrity": "sha512-2OpQcesiYsMilrTzgcTafSGexd9UryRQRuHudIcOn0YaqvvzNpnhVZMVuJMH93fJv/iaZYp3138rgUKOdHhtSw==", "peerDependencies": { "react": "*", "react-native": "*", diff --git a/package.json b/package.json index 3dd91101f02f..78c2f5ec4415 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.12.0", + "react-native-keyboard-controller": "^1.12.1", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", From acfbdcb0f8bb35655522bbcc858e7411cbf7063b Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 4 Jun 2024 17:25:03 +0200 Subject: [PATCH 010/593] chore: apply prettier --- .../HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx | 4 +++- src/pages/home/report/ReportActionItem.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 902489731d94..d8b13f08909b 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -93,7 +93,9 @@ function ImageRenderer({tnode}: ImageRendererProps) { Navigation.navigate(route); } }} - onLongPress={(event) => onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report)))} + onLongPress={(event) => + onShowContextMenu(() => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))) + } shouldUseHapticsOnLongPress accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={translate('accessibilityHints.viewAttachment')} diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 58d0e606c8a9..196dd2a825fb 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -5,8 +5,8 @@ import {InteractionManager, View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; -import {AttachmentContext} from '@components/AttachmentContext'; import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; +import {AttachmentContext} from '@components/AttachmentContext'; import Button from '@components/Button'; import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; From 41044a199b8734617e0d8fe5af9a10ce02cd1fe9 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 20 Jun 2024 15:04:42 +0300 Subject: [PATCH 011/593] chore: keep important changes from experiments --- ...ated+3.8.1+003+concurrent-rn-updates.patch | 79 +++++++++++++++++++ src/hooks/useWorkletStateMachine.ts | 5 +- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 patches/react-native-reanimated+3.8.1+003+concurrent-rn-updates.patch diff --git a/patches/react-native-reanimated+3.8.1+003+concurrent-rn-updates.patch b/patches/react-native-reanimated+3.8.1+003+concurrent-rn-updates.patch new file mode 100644 index 000000000000..02a298f4c5a6 --- /dev/null +++ b/patches/react-native-reanimated+3.8.1+003+concurrent-rn-updates.patch @@ -0,0 +1,79 @@ +diff --git a/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.cpp b/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.cpp +index b34579d..87513aa 100644 +--- a/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.cpp ++++ b/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.cpp +@@ -418,6 +418,24 @@ bool NativeReanimatedModule::isThereAnyLayoutProp( + return false; + } + ++jsi::Object NativeReanimatedModule::getUIProps( ++ jsi::Runtime &rt, ++ const jsi::Object &props) { ++ jsi::Object res = jsi::Object(rt); ++ const jsi::Array propNames = props.getPropertyNames(rt); ++ for (size_t i = 0; i < propNames.size(rt); ++i) { ++ const std::string propName = ++ propNames.getValueAtIndex(rt, i).asString(rt).utf8(rt); ++ bool isLayoutProp = ++ nativePropNames_.find(propName) != nativePropNames_.end(); ++ if (!isLayoutProp) { ++ const jsi::Value &propValue = props.getProperty(rt, propName.c_str()); ++ res.setProperty(rt, propName.c_str(), propValue); ++ } ++ } ++ return res; ++} ++ + jsi::Value NativeReanimatedModule::filterNonAnimatableProps( + jsi::Runtime &rt, + const jsi::Value &props) { +@@ -565,13 +583,15 @@ void NativeReanimatedModule::performOperations() { + } + } + ++ // If there's no layout props to be updated, we can apply the updates ++ // directly onto the components and skip the commit. ++ for (const auto &[shadowNode, props] : copiedOperationsQueue) { ++ Tag tag = shadowNode->getTag(); ++ jsi::Object uiProps = getUIProps(rt, props->asObject(rt)); ++ synchronouslyUpdateUIPropsFunction_(rt, tag, uiProps); ++ } ++ + if (!hasLayoutUpdates) { +- // If there's no layout props to be updated, we can apply the updates +- // directly onto the components and skip the commit. +- for (const auto &[shadowNode, props] : copiedOperationsQueue) { +- Tag tag = shadowNode->getTag(); +- synchronouslyUpdateUIPropsFunction_(rt, tag, props->asObject(rt)); +- } + return; + } + +diff --git a/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.h b/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.h +index 9f8c32d..cb31205 100644 +--- a/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.h ++++ b/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.h +@@ -163,6 +163,7 @@ class NativeReanimatedModule : public NativeReanimatedModuleSpec { + + #ifdef RCT_NEW_ARCH_ENABLED + bool isThereAnyLayoutProp(jsi::Runtime &rt, const jsi::Object &props); ++ jsi::Object getUIProps(jsi::Runtime &rt, const jsi::Object &props); + jsi::Value filterNonAnimatableProps( + jsi::Runtime &rt, + const jsi::Value &props); +diff --git a/node_modules/react-native-reanimated/apple/REANodesManager.mm b/node_modules/react-native-reanimated/apple/REANodesManager.mm +index ed36c99..0c64925 100644 +--- a/node_modules/react-native-reanimated/apple/REANodesManager.mm ++++ b/node_modules/react-native-reanimated/apple/REANodesManager.mm +@@ -432,9 +432,9 @@ - (void)synchronouslyUpdateViewOnUIThread:(nonnull NSNumber *)viewTag props:(non + REAUIView *componentView = + [componentViewRegistry findComponentViewWithTag:[viewTag integerValue]]; + +- NSSet *propKeysManagedByAnimated = [componentView propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN]; ++ // NSSet *propKeysManagedByAnimated = [componentView propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN]; + [surfacePresenter synchronouslyUpdateViewOnUIThread:viewTag props:uiProps]; +- [componentView setPropKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN:propKeysManagedByAnimated]; ++ // [componentView setPropKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN:propKeysManagedByAnimated]; + + // `synchronouslyUpdateViewOnUIThread` does not flush props like `backgroundColor` etc. + // so that's why we need to call `finalizeUpdates` here. diff --git a/src/hooks/useWorkletStateMachine.ts b/src/hooks/useWorkletStateMachine.ts index f0c3fde0012d..5bc81a0629b1 100644 --- a/src/hooks/useWorkletStateMachine.ts +++ b/src/hooks/useWorkletStateMachine.ts @@ -21,6 +21,9 @@ type State

= { 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. @@ -83,7 +86,7 @@ function useWorkletStateMachine

(stateMachine: StateMachine, initialState: Sta } // eslint-disable-next-line @typescript-eslint/unbound-method, @typescript-eslint/restrict-template-expressions - runOnJS(Log.client)(`[StateMachine] ${message}. Params: ${params}`); + runOnJS(client)(`[StateMachine] ${message}. Params: ${JSON.stringify(params)}`); }, []); const transitionWorklet = useCallback( From 843bf2580a2c1102b27fab83f0711909a9c90d55 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 20 Jun 2024 15:56:10 +0300 Subject: [PATCH 012/593] fix: don't mount two providers --- src/App.tsx | 2 -- src/components/KeyboardHandlerProvider.tsx | 12 ------------ 2 files changed, 14 deletions(-) delete mode 100644 src/components/KeyboardHandlerProvider.tsx diff --git a/src/App.tsx b/src/App.tsx index 4adadbce7cc9..56c3b167ddac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,6 @@ import CustomStatusBarAndBackgroundContextProvider from './components/CustomStat import ErrorBoundary from './components/ErrorBoundary'; import HTMLEngineProvider from './components/HTMLEngineProvider'; import InitialURLContextProvider from './components/InitialURLContextProvider'; -import KeyboardHandlerProvider from './components/KeyboardHandlerProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; @@ -82,7 +81,6 @@ function App({url}: AppProps) { CustomStatusBarAndBackgroundContextProvider, ActiveElementRoleProvider, ActiveWorkspaceContextProvider, - KeyboardHandlerProvider, ActionSheetAwareScrollView.ActionSheetAwareScrollViewProvider, ReportIDsContextProvider, PlaybackContextProvider, diff --git a/src/components/KeyboardHandlerProvider.tsx b/src/components/KeyboardHandlerProvider.tsx deleted file mode 100644 index dc208d10aeb3..000000000000 --- a/src/components/KeyboardHandlerProvider.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type {PropsWithChildren} from 'react'; -import React from 'react'; -import {Platform} from 'react-native'; -import {KeyboardProvider} from 'react-native-keyboard-controller'; - -type Props = PropsWithChildren; - -function KeyboardHandlerProvider({children}: Props) { - return {children}; -} - -export default KeyboardHandlerProvider; From 7d2df0a0b8a89bf5371487edc22beb62f2e23b3b Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 20 Jun 2024 18:05:17 +0300 Subject: [PATCH 013/593] fix: + button transitions --- .../ActionSheetKeyboardSpace.tsx | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index cfef99c87d08..a7154ac9d19d 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -155,10 +155,12 @@ function ActionSheetKeyboardSpace(props: ViewProps) { return Math.max(keyboard.heightWhenOpened.value - keyboard.height.value - safeArea.bottom, 0) + Math.max(elementOffset, 0); } + console.log(111, 0); return withSpring(0, config); } case States.POPOVER_CLOSED: { + console.log(112, 0); return withSpring(0, config, () => { transition({ type: Actions.END_TRANSITION, @@ -171,12 +173,15 @@ function ActionSheetKeyboardSpace(props: ViewProps) { case States.POPOVER_OPEN: { if (popoverHeight) { if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { + console.log(113, elementOffset < 0 ? 0 : elementOffset, elementOffset); return withSpring(elementOffset < 0 ? 0 : elementOffset, config); } + console.log(114, Math.max(previousElementOffset, 0), previousElementOffset); return withSpring(Math.max(previousElementOffset, 0), config); } + console.log(115, 0); return 0; } @@ -185,6 +190,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { // when item is higher than keyboard and bottom sheet // we should just stay in place if (elementOffset < 0) { + console.log(116, invertedKeyboardHeight); return invertedKeyboardHeight; } @@ -193,65 +199,93 @@ function ActionSheetKeyboardSpace(props: ViewProps) { const previousOffset = invertedKeyboardHeight + previousElementOffset; if (previousElementOffset === 0 || nextOffset > previousOffset) { + console.log(117, nextOffset); return withSpring(nextOffset, config); } + console.log(118, previousOffset); return previousOffset; } - + console.log(119, nextOffset); return nextOffset; } case States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED: case States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN: { + // this transition is extremely slow and we may not have `popoverHeight` when keyboard is hiding + // so we run two fold animation: + // - when keyboard is hiding -> we return `0` and thus the content is sticky to composer + // - when keyboard is closed and we have `popoverHeight` (i. e. popup was measured) -> we run spring animation + if (keyboard.state.value === KeyboardState.CLOSING) { + console.log(1200, 0); + return 0; + } + if (keyboard.progress.value === 0) { + console.log(1201, keyboard.progress.value, interpolate(keyboard.progress.value, [0, 1], [popoverHeight - composerHeight, 0]), popoverHeight, composerHeight); + return withSpring(popoverHeight - composerHeight, config); + } + + // when keyboard appears -> we already have all values so we do interpolation based on keyboard position + console.log(1202, keyboard.progress.value, interpolate(keyboard.progress.value, [0, 1], [popoverHeight - composerHeight, 0]), popoverHeight, composerHeight); return interpolate(keyboard.progress.value, [0, 1], [popoverHeight - composerHeight, 0]); } case States.CALL_POPOVER_WITH_KEYBOARD_OPEN: { if (keyboard.height.value > 0) { + console.log(121, 0); return 0; } - + console.log(122, lastKeyboardHeight, popoverHeight - composerHeight); return setInitialValueAndRunAnimation(lastKeyboardHeight, withSpring(popoverHeight - composerHeight, config)); } case States.CALL_POPOVER_WITH_KEYBOARD_CLOSED: { // keyboard is opened if (keyboard.height.value > 0) { + console.log(123, 0); return 0; } + console.log(124, lastKeyboardHeight); return withSpring(lastKeyboardHeight, config); } case States.EMOJI_PICKER_WITH_KEYBOARD_OPEN: { if (keyboard.state.value === KeyboardState.CLOSED) { + console.log(125, popoverHeight - composerHeight); return popoverHeight - composerHeight; } + console.log(126, 0); return 0; } case States.KEYBOARD_POPOVER_CLOSED: { if (keyboard.heightWhenOpened.value === keyboard.height.value) { + console.log(127, 0); return 0; } + console.log(128, popoverHeight - composerHeight); return popoverHeight - composerHeight; } case States.KEYBOARD_POPOVER_OPEN: { if (keyboard.state.value === KeyboardState.OPEN) { + console.log(129, 0); return 0; } const nextOffset = elementOffset + lastKeyboardHeight; if (keyboard.state.value === KeyboardState.CLOSED && nextOffset > invertedKeyboardHeight) { + console.log(130, nextOffset < 0 ? 0 : nextOffset, nextOffset); return withSpring(nextOffset < 0 ? 0 : nextOffset, config); } if (elementOffset < 0) { + console.log(131, lastKeyboardHeight - keyboardHeight); return lastKeyboardHeight - keyboardHeight; } + console.log(132, lastKeyboardHeight); return lastKeyboardHeight; } @@ -259,26 +293,32 @@ function ActionSheetKeyboardSpace(props: ViewProps) { if (elementOffset < 0) { transition({type: Actions.END_TRANSITION}); + console.log(133, 0); return 0; } if (keyboard.state.value === KeyboardState.CLOSED) { + console.log(134, elementOffset + lastKeyboardHeight); return elementOffset + lastKeyboardHeight; } if (keyboard.height.value > 0) { + console.log(135, keyboard.heightWhenOpened.value - keyboard.height.value + elementOffset); return keyboard.heightWhenOpened.value - keyboard.height.value + elementOffset; } + console.log(136, elementOffset + lastKeyboardHeight); return withTiming(elementOffset + lastKeyboardHeight, { duration: 0, }); } case States.EDIT_MESSAGE: { + console.log(137, 0); return 0; } default: + console.log(138, 0); return 0; } }, []); From 5d3c6e7aa0d4cfbc1fda0e53b273597a4df4a786 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Fri, 19 Jul 2024 17:52:11 +0200 Subject: [PATCH 014/593] use executeOnUIRuntimeSync --- src/hooks/useWorkletStateMachine.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useWorkletStateMachine.ts b/src/hooks/useWorkletStateMachine.ts index 5bc81a0629b1..d54a586b1a42 100644 --- a/src/hooks/useWorkletStateMachine.ts +++ b/src/hooks/useWorkletStateMachine.ts @@ -1,5 +1,5 @@ import {useCallback} from 'react'; -import {runOnJS, runOnUI, useSharedValue} from 'react-native-reanimated'; +import {executeOnUIRuntimeSync, runOnJS, useSharedValue} from 'react-native-reanimated'; import Log from '@libs/Log'; // When you need to debug state machine change this to true @@ -150,12 +150,12 @@ function useWorkletStateMachine

(stateMachine: StateMachine, initialState: Sta }, [currentState, initialState, log]); const reset = useCallback(() => { - runOnUI(resetWorklet)(); + executeOnUIRuntimeSync(resetWorklet)(); }, [resetWorklet]); const transition = useCallback( (action: ActionWithPayload

) => { - runOnUI(transitionWorklet)(action); + executeOnUIRuntimeSync(transitionWorklet)(action); }, [transitionWorklet], ); From 67d5dcab3aa251ae6d2062170335df4a22ecfa95 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Thu, 25 Jul 2024 16:03:59 +0200 Subject: [PATCH 015/593] reset worklet on UI thread --- src/hooks/useWorkletStateMachine.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useWorkletStateMachine.ts b/src/hooks/useWorkletStateMachine.ts index d54a586b1a42..b9c02faa7bd1 100644 --- a/src/hooks/useWorkletStateMachine.ts +++ b/src/hooks/useWorkletStateMachine.ts @@ -1,5 +1,5 @@ import {useCallback} from 'react'; -import {executeOnUIRuntimeSync, runOnJS, useSharedValue} from 'react-native-reanimated'; +import {executeOnUIRuntimeSync, runOnJS, runOnUI, useSharedValue} from 'react-native-reanimated'; import Log from '@libs/Log'; // When you need to debug state machine change this to true @@ -150,7 +150,7 @@ function useWorkletStateMachine

(stateMachine: StateMachine, initialState: Sta }, [currentState, initialState, log]); const reset = useCallback(() => { - executeOnUIRuntimeSync(resetWorklet)(); + runOnUI(resetWorklet)(); }, [resetWorklet]); const transition = useCallback( From cbeec879a57b73893134b37a47069c0cec2aa37c Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Fri, 26 Jul 2024 12:56:12 +0200 Subject: [PATCH 016/593] fix jumping while KEYBOARD_OPEN --- .../ActionSheetKeyboardSpace.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index a7154ac9d19d..afd6bcf7fdea 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -97,10 +97,9 @@ function ActionSheetKeyboardSpace(props: ViewProps) { // Reset state machine when component unmounts useEffect(() => () => resetStateMachine(), [resetStateMachine]); - useAnimatedReaction( () => keyboard.state.value, - (lastState) => { + (lastState, prev) => { if (lastState === syncLocalWorkletState.lastState) { return; } @@ -149,9 +148,10 @@ function ActionSheetKeyboardSpace(props: ViewProps) { // Depending on the current and sometimes previous state we can return // either animation or just a value + switch (current.state) { case States.KEYBOARD_OPEN: { - if (previous.state === States.KEYBOARD_CLOSED_POPOVER) { + if (previous.state === States.KEYBOARD_CLOSED_POPOVER || (previous.state === States.KEYBOARD_OPEN && elementOffset < 0)) { return Math.max(keyboard.heightWhenOpened.value - keyboard.height.value - safeArea.bottom, 0) + Math.max(elementOffset, 0); } @@ -285,7 +285,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { return lastKeyboardHeight - keyboardHeight; } - console.log(132, lastKeyboardHeight); + console.log(132, lastKeyboardHeight, lastKeyboardHeight - keyboardHeight, keyboardHeight); return lastKeyboardHeight; } @@ -303,7 +303,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { } if (keyboard.height.value > 0) { - console.log(135, keyboard.heightWhenOpened.value - keyboard.height.value + elementOffset); + console.log(135, keyboard.heightWhenOpened.value - keyboard.height.value + elementOffset, 'elementOffset', elementOffset); return keyboard.heightWhenOpened.value - keyboard.height.value + elementOffset; } From 812e9ca431df6983111ec8c8846327bce2d07fd1 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Fri, 26 Jul 2024 12:56:43 +0200 Subject: [PATCH 017/593] update controller --- ...ct-native-keyboard-controller+1.12.2.patch | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 patches/react-native-keyboard-controller+1.12.2.patch diff --git a/patches/react-native-keyboard-controller+1.12.2.patch b/patches/react-native-keyboard-controller+1.12.2.patch deleted file mode 100644 index 3c8034354481..000000000000 --- a/patches/react-native-keyboard-controller+1.12.2.patch +++ /dev/null @@ -1,39 +0,0 @@ -diff --git a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt -index 83884d8..5d9e989 100644 ---- a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt -+++ b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt -@@ -99,12 +99,12 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R - } - - private fun goToEdgeToEdge(edgeToEdge: Boolean) { -- reactContext.currentActivity?.let { -- WindowCompat.setDecorFitsSystemWindows( -- it.window, -- !edgeToEdge, -- ) -- } -+ // reactContext.currentActivity?.let { -+ // WindowCompat.setDecorFitsSystemWindows( -+ // it.window, -+ // !edgeToEdge, -+ // ) -+ // } - } - - private fun setupKeyboardCallbacks() { -@@ -158,13 +158,13 @@ class EdgeToEdgeReactViewGroup(private val reactContext: ThemedReactContext) : R - // region State managers - private fun enable() { - this.goToEdgeToEdge(true) -- this.setupWindowInsets() -+ // this.setupWindowInsets() - this.setupKeyboardCallbacks() - } - - private fun disable() { - this.goToEdgeToEdge(false) -- this.setupWindowInsets() -+ // this.setupWindowInsets() - this.removeKeyboardCallbacks() - } - // endregion \ No newline at end of file From 05677c9408bdd663e0a1c7ffb2446c6e393e50af Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Fri, 26 Jul 2024 17:20:31 +0200 Subject: [PATCH 018/593] update SafeAreaPaddings --- .../ActionSheetKeyboardSpace.tsx | 69 +++++++------------ 1 file changed, 26 insertions(+), 43 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index afd6bcf7fdea..c2214dea813b 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -16,6 +16,12 @@ const KeyboardState = { CLOSED: 4, }; +const SPRING_CONFIG = { + mass: 3, + stiffness: 1000, + damping: 500, +}; + const useAnimatedKeyboard = () => { const state = useSharedValue(KeyboardState.UNKNOWN); const height = useSharedValue(0); @@ -31,12 +37,7 @@ const useAnimatedKeyboard = () => { if (e.height === 0) { heightWhenOpened.value = height.value; } - - if (e.height > 0) { - state.value = KeyboardState.OPENING; - } else { - state.value = KeyboardState.CLOSING; - } + state.value = e.height > 0 ? KeyboardState.OPENING : KeyboardState.CLOSING; }, onMove: (e) => { 'worklet'; @@ -46,13 +47,7 @@ const useAnimatedKeyboard = () => { }, onEnd: (e) => { 'worklet'; - - if (e.height > 0) { - state.value = KeyboardState.OPEN; - } else { - state.value = KeyboardState.CLOSED; - } - + state.value = e.height > 0 ? KeyboardState.OPEN : KeyboardState.CLOSED; height.value = e.height; progress.value = e.progress; }, @@ -77,12 +72,6 @@ const useSafeAreaPaddings = () => { return {top: paddingTop, bottom: paddingBottom}; }; -const config = { - mass: 3, - stiffness: 1000, - damping: 500, -}; - function ActionSheetKeyboardSpace(props: ViewProps) { const styles = useThemeStyles(); const safeArea = useSafeAreaPaddings(); @@ -97,23 +86,22 @@ function ActionSheetKeyboardSpace(props: ViewProps) { // Reset state machine when component unmounts useEffect(() => () => resetStateMachine(), [resetStateMachine]); + // eslint-disable-next-line arrow-body-style + // useEffect(() => { + // return () => resetStateMachine(); + // }, [resetStateMachine]); + useAnimatedReaction( () => keyboard.state.value, - (lastState, prev) => { + (lastState) => { if (lastState === syncLocalWorkletState.lastState) { return; } - syncLocalWorkletState.lastState = lastState; - if (lastState === KeyboardState.OPEN) { - runOnJS(transitionActionSheetState)({ - type: Actions.OPEN_KEYBOARD, - }); + runOnJS(transitionActionSheetState)({type: Actions.OPEN_KEYBOARD}); } else if (lastState === KeyboardState.CLOSED) { - runOnJS(transitionActionSheetState)({ - type: Actions.CLOSE_KEYBOARD, - }); + runOnJS(transitionActionSheetState)({type: Actions.CLOSE_KEYBOARD}); } }, [], @@ -125,22 +113,17 @@ function ActionSheetKeyboardSpace(props: ViewProps) { // we don't need to run any additional logic // it will always return 0 for idle state if (current.state === States.IDLE) { - return withSpring(0, config); + return withSpring(0, SPRING_CONFIG); } const keyboardHeight = keyboard.height.value === 0 ? 0 : keyboard.height.value - safeArea.bottom; // sometimes we need to know the last keyboard height const lastKeyboardHeight = keyboard.heightWhenOpened.value - safeArea.bottom; - const {popoverHeight = 0, fy, height, composerHeight = 0} = current.payload ?? {}; - const invertedKeyboardHeight = keyboard.state.value === KeyboardState.CLOSED ? lastKeyboardHeight : 0; - const elementOffset = fy !== undefined && height !== undefined && popoverHeight !== undefined ? fy + safeArea.top + height - (windowHeight - popoverHeight) : 0; - // when the sate is not idle we know for sure we have 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) @@ -156,12 +139,12 @@ function ActionSheetKeyboardSpace(props: ViewProps) { } console.log(111, 0); - return withSpring(0, config); + return withSpring(0, SPRING_CONFIG); } case States.POPOVER_CLOSED: { console.log(112, 0); - return withSpring(0, config, () => { + return withSpring(0, SPRING_CONFIG, () => { transition({ type: Actions.END_TRANSITION, }); @@ -174,11 +157,11 @@ function ActionSheetKeyboardSpace(props: ViewProps) { if (popoverHeight) { if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { console.log(113, elementOffset < 0 ? 0 : elementOffset, elementOffset); - return withSpring(elementOffset < 0 ? 0 : elementOffset, config); + return withSpring(elementOffset < 0 ? 0 : elementOffset, SPRING_CONFIG); } console.log(114, Math.max(previousElementOffset, 0), previousElementOffset); - return withSpring(Math.max(previousElementOffset, 0), config); + return withSpring(Math.max(previousElementOffset, 0), SPRING_CONFIG); } console.log(115, 0); @@ -200,7 +183,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { if (previousElementOffset === 0 || nextOffset > previousOffset) { console.log(117, nextOffset); - return withSpring(nextOffset, config); + return withSpring(nextOffset, SPRING_CONFIG); } console.log(118, previousOffset); @@ -222,7 +205,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { } if (keyboard.progress.value === 0) { console.log(1201, keyboard.progress.value, interpolate(keyboard.progress.value, [0, 1], [popoverHeight - composerHeight, 0]), popoverHeight, composerHeight); - return withSpring(popoverHeight - composerHeight, config); + return withSpring(popoverHeight - composerHeight, SPRING_CONFIG); } // when keyboard appears -> we already have all values so we do interpolation based on keyboard position @@ -235,7 +218,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { return 0; } console.log(122, lastKeyboardHeight, popoverHeight - composerHeight); - return setInitialValueAndRunAnimation(lastKeyboardHeight, withSpring(popoverHeight - composerHeight, config)); + return setInitialValueAndRunAnimation(lastKeyboardHeight, withSpring(popoverHeight - composerHeight, SPRING_CONFIG)); } case States.CALL_POPOVER_WITH_KEYBOARD_CLOSED: { // keyboard is opened @@ -245,7 +228,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { } console.log(124, lastKeyboardHeight); - return withSpring(lastKeyboardHeight, config); + return withSpring(lastKeyboardHeight, SPRING_CONFIG); } case States.EMOJI_PICKER_WITH_KEYBOARD_OPEN: { if (keyboard.state.value === KeyboardState.CLOSED) { @@ -277,7 +260,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { if (keyboard.state.value === KeyboardState.CLOSED && nextOffset > invertedKeyboardHeight) { console.log(130, nextOffset < 0 ? 0 : nextOffset, nextOffset); - return withSpring(nextOffset < 0 ? 0 : nextOffset, config); + return withSpring(nextOffset < 0 ? 0 : nextOffset, SPRING_CONFIG); } if (elementOffset < 0) { From 31a74624418532d667e4eb7d35b55cd33f1fed40 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Fri, 26 Jul 2024 17:20:57 +0200 Subject: [PATCH 019/593] bump keyboard-controller --- ios/Podfile.lock | 4 ++-- package-lock.json | 10 +++++----- package.json | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e7854c886c75..5729dbbd2906 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1303,7 +1303,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-keyboard-controller (1.12.2): + - react-native-keyboard-controller (1.12.6): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -2585,7 +2585,7 @@ SPEC CHECKSUMS: react-native-geolocation: 580c86eb531c0aaf7a14bc76fd2983ce47ca58aa react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440 react-native-key-command: 28ccfa09520e7d7e30739480dea4df003493bfe8 - react-native-keyboard-controller: 47c01b0741ae5fc84e53cf282e61cfa5c2edb19b + react-native-keyboard-controller: 87bd777183a9e55c455670c3abbb9974c7c7f77f react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: 02d31de0e08ab043d48f2a1a8baade109d7b6ca5 react-native-pager-view: c7372cab7caef173f7f81d78520fe21f08805020 diff --git a/package-lock.json b/package-lock.json index 2ced5738cca8..887809d84a77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,7 +97,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.12.2", + "react-native-keyboard-controller": "^1.12.6", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", @@ -36893,13 +36893,13 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.2.tgz", - "integrity": "sha512-10Sy0+neSHGJxOmOxrUJR8TQznnrQ+jTFQtM1PP6YnblNQeAw1eOa+lO6YLGenRr5WuNSMZbks/3Ay0e2yMKLw==", + "version": "1.12.6", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.6.tgz", + "integrity": "sha512-TPqgEelsPOjdmsKmV3iGi1+eG3hzcCXVzJo4F54JeDoCOtk/QP5Qwu586RRgAganogs4XULFRRFXLx8u73GoYw==", "peerDependencies": { "react": "*", "react-native": "*", - "react-native-reanimated": ">=2.3.0" + "react-native-reanimated": ">=2.11.0" } }, "node_modules/react-native-launch-arguments": { diff --git a/package.json b/package.json index 81e963e12671..8b667c2c3517 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#bf3ad41a61c4f6f80ed4d497599ef5247a2dd002", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.12.2", + "react-native-keyboard-controller": "^1.12.6", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", From 6b205ccc38710a3594fed8a24f8e5679139840a9 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Fri, 26 Jul 2024 17:23:50 +0200 Subject: [PATCH 020/593] lint --- .../BaseAnchorForAttachmentsOnly.tsx | 9 ++++---- src/components/ConfirmContent.tsx | 5 +++- .../HTMLRenderers/ImageRenderer.tsx | 23 +++++++++---------- .../HTMLRenderers/MentionUserRenderer.tsx | 9 ++++---- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx index 15fae49f072d..a003181883b2 100644 --- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx +++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx @@ -53,11 +53,10 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow }} onPressIn={onPressIn} onPressOut={onPressOut} - onLongPress={ - (event) => - onShowContextMenu(() => - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)), - ) + onLongPress={(event) => + onShowContextMenu(() => + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)), + ) } shouldUseHapticsOnLongPress accessibilityLabel={displayName} diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index 721666ee240c..b3bbf891bf06 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -162,7 +162,10 @@ function ConfirmContent({ )} - + {shouldShowDismissIcon && ( diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 2d2282cc869a..f20ae7ac46e8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -93,18 +93,17 @@ function ImageRenderer({tnode}: ImageRendererProps) { Navigation.navigate(route); } }} - onLongPress={ - (event) => - onShowContextMenu(() => - showContextMenuForReport( - event, - anchor, - report?.reportID ?? '-1', - action, - checkIfContextMenuActive, - ReportUtils.isArchivedRoom(report, reportNameValuePairs), - ), - ) + onLongPress={(event) => + onShowContextMenu(() => + showContextMenuForReport( + event, + anchor, + report?.reportID ?? '-1', + action, + checkIfContextMenuActive, + ReportUtils.isArchivedRoom(report, reportNameValuePairs), + ), + ) } shouldUseHapticsOnLongPress accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index e005212a0755..34a5b5e19298 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -86,11 +86,10 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive}) => ( - onShowContextMenu(() => - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)), - ) + onLongPress={(event) => + onShowContextMenu(() => + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)), + ) } onPress={(event) => { event.preventDefault(); From 8a27af57067bc25b9aa79eb25e271333f6d34a43 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Wed, 7 Aug 2024 14:03:20 +0200 Subject: [PATCH 021/593] add more states and actions --- .../ActionSheetAwareScrollViewContext.tsx | 19 ++++++++++++++++++- .../HTMLRenderers/PreRenderer.tsx | 19 ++++++------------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx index f592146659f4..f38d00a50a30 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -53,6 +53,7 @@ const Actions = { POPOVER_ANY_ACTION: 'POPOVER_ANY_ACTION', OPEN_EMOJI_PICKER_POPOVER: 'OPEN_EMOJI_PICKER_POPOVER', OPEN_EMOJI_PICKER_POPOVER_STANDALONE: 'OPEN_EMOJI_PICKER_POPOVER_STANDALONE', + CLOSE_EMOJI_PICKER_POPOVER_STANDALONE: 'CLOSE_EMOJI_PICKER_POPOVER_STANDALONE', CLOSE_EMOJI_PICKER_POPOVER: 'CLOSE_EMOJI_PICKER_POPOVER', MEASURE_EMOJI_PICKER_POPOVER: 'MEASURE_EMOJI_PICKER_POPOVER', HIDE_WITHOUT_ANIMATION: 'HIDE_WITHOUT_ANIMATION', @@ -65,7 +66,7 @@ const Actions = { SHOW_ATTACHMENTS_POPOVER: 'SHOW_ATTACHMENTS_POPOVER', CLOSE_ATTACHMENTS_POPOVER: 'CLOSE_ATTACHMENTS_POPOVER', SHOW_ATTACHMENTS_PICKER_POPOVER: 'SHOW_ATTACHMENTS_PICKER_POPOVER', - CLOSE_EMOJI_PICKER_POPOVER_STANDALONE: 'CLOSE_EMOJI_PICKER_POPOVER_STANDALONE', + CLOSE_ATTACHMENTS_PICKER_POPOVER: 'CLOSE_ATTACHMENTS_PICKER_POPOVER', MEASURE_CALL_POPOVER: 'MEASURE_CALL_POPOVER', CLOSE_CALL_POPOVER: 'CLOSE_CALL_POPOVER', }; @@ -88,6 +89,8 @@ const States = { CALL_POPOVER_WITH_KEYBOARD_CLOSED: 'callPopoverWithKeyboardClosed', ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN: 'attachmentsPopoverWithKeyboardOpen', ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED: 'attachmentsPopoverWithKeyboardClosed', + ATTACHMENTS_POPOVER_OPEN: 'attachmentsPopoverOpen', + ATTACHMENTS_POPOVER_CLOSED: 'attachmentsPopoverClosed', MODAL_DELETED: 'modalDeleted', MODAL_WITH_KEYBOARD_OPEN_DELETED: 'modalWithKeyboardOpenDeleted', EDIT_MESSAGE: 'editMessage', @@ -101,6 +104,8 @@ const STATE_MACHINE = { [Actions.MEASURE_COMPOSER]: States.IDLE, [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, [Actions.SHOW_ATTACHMENTS_PICKER_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, + [Actions.OPEN_EMOJI_PICKER_POPOVER_STANDALONE]: States.EMOJI_PICKER_POPOVER_OPEN, + [Actions.SHOW_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_OPEN, }, [States.POPOVER_OPEN]: { [Actions.CLOSE_POPOVER]: States.POPOVER_CLOSED, @@ -118,6 +123,7 @@ const STATE_MACHINE = { [States.EMOJI_PICKER_POPOVER_OPEN]: { [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, [Actions.CLOSE_EMOJI_PICKER_POPOVER]: States.POPOVER_CLOSED, + [Actions.CLOSE_EMOJI_PICKER_POPOVER_STANDALONE]: States.KEYBOARD_POPOVER_CLOSED, }, [States.MODAL_DELETED]: { [Actions.MEASURE_CONFIRM_MODAL]: States.MODAL_DELETED, @@ -169,6 +175,17 @@ const STATE_MACHINE = { [Actions.MEASURE_COMPOSER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED, }, + + [States.ATTACHMENTS_POPOVER_OPEN]: { + [Actions.MEASURE_POPOVER]: States.ATTACHMENTS_POPOVER_OPEN, + [Actions.MEASURE_COMPOSER]: States.ATTACHMENTS_POPOVER_OPEN, + [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_CLOSED, + }, + [States.ATTACHMENTS_POPOVER_CLOSED]: { + [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_CLOSED, + [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, + }, + [States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED]: { [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, }, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index 72a89a183ec9..54f1a7ec77b7 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -39,19 +39,12 @@ function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...d onPress={onPressIn ?? (() => {})} onPressIn={onPressIn} onPressOut={onPressOut} - onLongPress={ - (event) =>console.log(111, 0); - onShowContextMenu(() => - showContextMenuForReport( - event, - anchor, - report?.reportID ?? '-1', - action, - checkIfContextMenuActive, - ReportUtils.isArchivedRoom(report, reportNameValuePairs), - ), - ) - } + onLongPress={(event) => { + console.log(111, 0); + onShowContextMenu(() => + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)), + ); + }} shouldUseHapticsOnLongPress role={CONST.ROLE.PRESENTATION} accessibilityLabel={translate('accessibilityHints.prestyledText')} From 1123d522734f8363b83f29288887ac6489a7ff46 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Thu, 8 Aug 2024 18:09:46 +0200 Subject: [PATCH 022/593] attachment popover and emoji picker without keyboard --- .../ActionSheetKeyboardSpace.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index c2214dea813b..02a1e4e8d8e7 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -47,6 +47,7 @@ const useAnimatedKeyboard = () => { }, onEnd: (e) => { 'worklet'; + state.value = e.height > 0 ? KeyboardState.OPEN : KeyboardState.CLOSED; height.value = e.height; progress.value = e.progress; @@ -152,7 +153,6 @@ function ActionSheetKeyboardSpace(props: ViewProps) { } case States.MODAL_DELETED: - case States.EMOJI_PICKER_POPOVER_OPEN: case States.POPOVER_OPEN: { if (popoverHeight) { if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { @@ -160,13 +160,28 @@ function ActionSheetKeyboardSpace(props: ViewProps) { return withSpring(elementOffset < 0 ? 0 : elementOffset, SPRING_CONFIG); } - console.log(114, Math.max(previousElementOffset, 0), previousElementOffset); + console.log(114, Math.max(previousElementOffset, 0)); return withSpring(Math.max(previousElementOffset, 0), SPRING_CONFIG); } console.log(115, 0); return 0; } + case States.ATTACHMENTS_POPOVER_OPEN: + case States.EMOJI_PICKER_POPOVER_OPEN: { + if (popoverHeight) { + if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { + console.log(98, elementOffset < 0 ? 0 : elementOffset, elementOffset); + return withSpring(elementOffset < 0 ? 0 : elementOffset, SPRING_CONFIG); + } + + console.log(99, Math.max(previousElementOffset, 0), previousElementOffset, popoverHeight - composerHeight); + return withSpring(popoverHeight - composerHeight, SPRING_CONFIG); + } + + console.log(1100, 0); + return 0; + } case States.MODAL_WITH_KEYBOARD_OPEN_DELETED: case States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN: { From 3e7680e77b672f01c9fcf251afe37e74eeb6fe0d Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Thu, 8 Aug 2024 18:10:47 +0200 Subject: [PATCH 023/593] EMOJI_PICKER_WITH_KEYBOARD_OPEN --- .../ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index 02a1e4e8d8e7..6dd6296ad0ab 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -252,7 +252,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { } console.log(126, 0); - return 0; + return Math.max(keyboard.heightWhenOpened.value - keyboard.height.value - safeArea.bottom, 0); } case States.KEYBOARD_POPOVER_CLOSED: { From 6281b7b1caf46c63c2e499d56422f247d41a38d2 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Thu, 8 Aug 2024 18:52:29 +0200 Subject: [PATCH 024/593] popover closing --- .../ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index 6dd6296ad0ab..68a0e91caa48 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -262,7 +262,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { } console.log(128, popoverHeight - composerHeight); - return popoverHeight - composerHeight; + return Math.max(keyboard.heightWhenOpened.value - keyboard.height.value - safeArea.bottom, 0); } case States.KEYBOARD_POPOVER_OPEN: { From b5c697f536bb29ae6cf623152547eb905f832478 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Thu, 8 Aug 2024 19:17:46 +0200 Subject: [PATCH 025/593] state machine --- .../ActionSheetAwareScrollViewContext.tsx | 5 ++--- src/components/EmojiPicker/EmojiPickerButton.tsx | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx index f38d00a50a30..6f0dad2b1b19 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -123,7 +123,7 @@ const STATE_MACHINE = { [States.EMOJI_PICKER_POPOVER_OPEN]: { [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, [Actions.CLOSE_EMOJI_PICKER_POPOVER]: States.POPOVER_CLOSED, - [Actions.CLOSE_EMOJI_PICKER_POPOVER_STANDALONE]: States.KEYBOARD_POPOVER_CLOSED, + [Actions.CLOSE_EMOJI_PICKER_POPOVER_STANDALONE]: States.POPOVER_CLOSED, }, [States.MODAL_DELETED]: { [Actions.MEASURE_CONFIRM_MODAL]: States.MODAL_DELETED, @@ -175,11 +175,10 @@ const STATE_MACHINE = { [Actions.MEASURE_COMPOSER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED, }, - [States.ATTACHMENTS_POPOVER_OPEN]: { [Actions.MEASURE_POPOVER]: States.ATTACHMENTS_POPOVER_OPEN, [Actions.MEASURE_COMPOSER]: States.ATTACHMENTS_POPOVER_OPEN, - [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_CLOSED, + [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.POPOVER_CLOSED, }, [States.ATTACHMENTS_POPOVER_CLOSED]: { [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_CLOSED, diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index 40e3cdd45f71..00389e3b9e10 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -31,7 +31,7 @@ type EmojiPickerButtonProps = { }; function EmojiPickerButton({isDisabled = false, id = '', emojiPickerID = '', shiftVertical = 0, onModalHide, onEmojiSelected}: EmojiPickerButtonProps) { - const actionSheetContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); + const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const emojiPopoverAnchor = useRef(null); @@ -43,12 +43,12 @@ function EmojiPickerButton({isDisabled = false, id = '', emojiPickerID = '', shi return; } - actionSheetContext.transitionActionSheetState({ + actionSheetAwareScrollViewContext.transitionActionSheetState({ type: ActionSheetAwareScrollView.Actions.OPEN_EMOJI_PICKER_POPOVER_STANDALONE, }); const onHide = () => { - actionSheetContext.transitionActionSheetState({ + actionSheetAwareScrollViewContext.transitionActionSheetState({ type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER_STANDALONE, }); From 426a5425df93bf60f46e76447e0d8ffaca9942ac Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Fri, 23 Aug 2024 11:12:10 +0200 Subject: [PATCH 026/593] keyboard-controller bump --- ios/Podfile.lock | 64 +++++++++++++++++++++++------------------------ package-lock.json | 10 ++++---- package.json | 2 +- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 01c4fc376d30..f6f7a0e8946a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -19,12 +19,12 @@ PODS: - Airship/Core - AirshipFrameworkProxy (5.1.1): - Airship (= 17.7.3) - - AirshipServiceExtension (17.8.0) - - AppAuth (1.6.2): - - AppAuth/Core (= 1.6.2) - - AppAuth/ExternalUserAgent (= 1.6.2) - - AppAuth/Core (1.6.2) - - AppAuth/ExternalUserAgent (1.6.2): + - AirshipServiceExtension (18.7.0) + - AppAuth (1.7.5): + - AppAuth/Core (= 1.7.5) + - AppAuth/ExternalUserAgent (= 1.7.5) + - AppAuth/Core (1.7.5) + - AppAuth/ExternalUserAgent (1.7.5): - AppAuth/Core - boost (1.83.0) - BVLinearGradient (2.8.1): @@ -184,44 +184,44 @@ PODS: - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30911.0, >= 2.30908.0) - PromisesObjC (< 3.0, >= 1.2) - - GoogleSignIn (7.0.0): - - AppAuth (~> 1.5) - - GTMAppAuth (< 3.0, >= 1.3) - - GTMSessionFetcher/Core (< 4.0, >= 1.1) - - GoogleUtilities/AppDelegateSwizzler (7.13.0): + - GoogleSignIn (7.1.0): + - AppAuth (< 2.0, >= 1.7.3) + - GTMAppAuth (< 5.0, >= 4.1.1) + - GTMSessionFetcher/Core (~> 3.3) + - GoogleUtilities/AppDelegateSwizzler (7.13.3): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (7.13.0): + - GoogleUtilities/Environment (7.13.3): - GoogleUtilities/Privacy - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/ISASwizzler (7.13.0): + - GoogleUtilities/ISASwizzler (7.13.3): - GoogleUtilities/Privacy - - GoogleUtilities/Logger (7.13.0): + - GoogleUtilities/Logger (7.13.3): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/MethodSwizzler (7.13.0): + - GoogleUtilities/MethodSwizzler (7.13.3): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/Network (7.13.0): + - GoogleUtilities/Network (7.13.3): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.13.0)": + - "GoogleUtilities/NSData+zlib (7.13.3)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (7.13.0) - - GoogleUtilities/Reachability (7.13.0): + - GoogleUtilities/Privacy (7.13.3) + - GoogleUtilities/Reachability (7.13.3): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (7.13.0): + - GoogleUtilities/UserDefaults (7.13.3): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GTMAppAuth (2.0.0): - - AppAuth/Core (~> 1.6) - - GTMSessionFetcher/Core (< 4.0, >= 1.5) - - GTMSessionFetcher/Core (3.3.1) + - GTMAppAuth (4.1.1): + - AppAuth/Core (~> 1.7) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher/Core (3.5.0) - hermes-engine (0.73.4): - hermes-engine/Pre-built (= 0.73.4) - hermes-engine/Pre-built (0.73.4) @@ -1303,7 +1303,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-keyboard-controller (1.12.6): + - react-native-keyboard-controller (1.13.2): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -2487,8 +2487,8 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: Airship: 5a6d3f8a982398940b0d48423bb9b8736717c123 AirshipFrameworkProxy: 7255f4ed9836dc2920f2f1ea5657ced4cee8a35c - AirshipServiceExtension: 0a5fb14c3fd1879355ab05a81d10f64512a4f79c - AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570 + AirshipServiceExtension: 7f00d1c36a7deddd435a8ef4a052aa9dbc67d357 + AppAuth: 501c04eda8a8d11f179dbe8637b7a91bb7e5d2fa boost: d3f49c53809116a5d38da093a8aa78bf551aed09 BVLinearGradient: 421743791a59d259aec53f4c58793aad031da2ca DoubleConversion: fea03f2699887d960129cc54bba7e52542b6f953 @@ -2514,10 +2514,10 @@ SPEC CHECKSUMS: glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 GoogleAppMeasurement: 5ba1164e3c844ba84272555e916d0a6d3d977e91 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a - GoogleSignIn: b232380cf495a429b8095d3178a8d5855b42e842 - GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 - GTMAppAuth: 99fb010047ba3973b7026e45393f51f27ab965ae - GTMSessionFetcher: 8a1b34ad97ebe6f909fb8b9b77fba99943007556 + GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db + GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 + GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 hermes-engine: b2669ce35fc4ac14f523b307aff8896799829fe2 libaom: 144606b1da4b5915a1054383c3a4459ccdb3c661 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 @@ -2565,7 +2565,7 @@ SPEC CHECKSUMS: react-native-geolocation: 580c86eb531c0aaf7a14bc76fd2983ce47ca58aa react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440 react-native-key-command: 28ccfa09520e7d7e30739480dea4df003493bfe8 - react-native-keyboard-controller: 87bd777183a9e55c455670c3abbb9974c7c7f77f + react-native-keyboard-controller: ed5da3350e5c500d1a250b453de9546e39c4568e react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: 02d31de0e08ab043d48f2a1a8baade109d7b6ca5 react-native-pager-view: ccd4bbf9fc7effaf8f91f8dae43389844d9ef9fa diff --git a/package-lock.json b/package-lock.json index 8312884fd874..bae1d1a548c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,7 +97,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#93399c6410de32966eb57085936ef6951398c2c3", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.12.6", + "react-native-keyboard-controller": "^1.13.2", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", @@ -37289,13 +37289,13 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.12.6", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.6.tgz", - "integrity": "sha512-TPqgEelsPOjdmsKmV3iGi1+eG3hzcCXVzJo4F54JeDoCOtk/QP5Qwu586RRgAganogs4XULFRRFXLx8u73GoYw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.13.2.tgz", + "integrity": "sha512-FZkxByqboqa2bq2fXtEnD7f78VmKbu5cHjEfubHfV2ZtkGolZ01XTqKkEQ172GvFSjC5iuF1L3h7C4g8R6Xq9Q==", "peerDependencies": { "react": "*", "react-native": "*", - "react-native-reanimated": ">=2.11.0" + "react-native-reanimated": ">=3.0.0" } }, "node_modules/react-native-launch-arguments": { diff --git a/package.json b/package.json index 0e5e5b3a086a..7c287686374a 100644 --- a/package.json +++ b/package.json @@ -153,7 +153,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#93399c6410de32966eb57085936ef6951398c2c3", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.12.6", + "react-native-keyboard-controller": "^1.13.2", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", From 1b4f2adc03e8b0334c6face08e72ad91a1418d67 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Tue, 3 Sep 2024 14:49:32 +0200 Subject: [PATCH 027/593] fix popover measure --- src/components/PopoverWithMeasuredContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx index ecfb6c9d3ca7..deb7d10a80f2 100644 --- a/src/components/PopoverWithMeasuredContent.tsx +++ b/src/components/PopoverWithMeasuredContent.tsx @@ -103,7 +103,7 @@ function PopoverWithMeasuredContent({ actionSheetAwareScrollViewContext.transitionActionSheetState({ type: ActionSheetAwareScrollView.Actions.MEASURE_POPOVER, payload: { - popoverHeight: Math.floor(popoverHeight), + popoverHeight: Math.floor(height), }, }); } From 3b17fd7788840db0ceea99f285670d1102eb0092 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Tue, 3 Sep 2024 14:50:22 +0200 Subject: [PATCH 028/593] clean patch after RN75 bump --- ...ated+3.8.1+003+concurrent-rn-updates.patch | 79 ------------------- 1 file changed, 79 deletions(-) delete mode 100644 patches/react-native-reanimated+3.8.1+003+concurrent-rn-updates.patch diff --git a/patches/react-native-reanimated+3.8.1+003+concurrent-rn-updates.patch b/patches/react-native-reanimated+3.8.1+003+concurrent-rn-updates.patch deleted file mode 100644 index 02a298f4c5a6..000000000000 --- a/patches/react-native-reanimated+3.8.1+003+concurrent-rn-updates.patch +++ /dev/null @@ -1,79 +0,0 @@ -diff --git a/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.cpp b/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.cpp -index b34579d..87513aa 100644 ---- a/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.cpp -+++ b/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.cpp -@@ -418,6 +418,24 @@ bool NativeReanimatedModule::isThereAnyLayoutProp( - return false; - } - -+jsi::Object NativeReanimatedModule::getUIProps( -+ jsi::Runtime &rt, -+ const jsi::Object &props) { -+ jsi::Object res = jsi::Object(rt); -+ const jsi::Array propNames = props.getPropertyNames(rt); -+ for (size_t i = 0; i < propNames.size(rt); ++i) { -+ const std::string propName = -+ propNames.getValueAtIndex(rt, i).asString(rt).utf8(rt); -+ bool isLayoutProp = -+ nativePropNames_.find(propName) != nativePropNames_.end(); -+ if (!isLayoutProp) { -+ const jsi::Value &propValue = props.getProperty(rt, propName.c_str()); -+ res.setProperty(rt, propName.c_str(), propValue); -+ } -+ } -+ return res; -+} -+ - jsi::Value NativeReanimatedModule::filterNonAnimatableProps( - jsi::Runtime &rt, - const jsi::Value &props) { -@@ -565,13 +583,15 @@ void NativeReanimatedModule::performOperations() { - } - } - -+ // If there's no layout props to be updated, we can apply the updates -+ // directly onto the components and skip the commit. -+ for (const auto &[shadowNode, props] : copiedOperationsQueue) { -+ Tag tag = shadowNode->getTag(); -+ jsi::Object uiProps = getUIProps(rt, props->asObject(rt)); -+ synchronouslyUpdateUIPropsFunction_(rt, tag, uiProps); -+ } -+ - if (!hasLayoutUpdates) { -- // If there's no layout props to be updated, we can apply the updates -- // directly onto the components and skip the commit. -- for (const auto &[shadowNode, props] : copiedOperationsQueue) { -- Tag tag = shadowNode->getTag(); -- synchronouslyUpdateUIPropsFunction_(rt, tag, props->asObject(rt)); -- } - return; - } - -diff --git a/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.h b/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.h -index 9f8c32d..cb31205 100644 ---- a/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.h -+++ b/node_modules/react-native-reanimated/Common/cpp/NativeModules/NativeReanimatedModule.h -@@ -163,6 +163,7 @@ class NativeReanimatedModule : public NativeReanimatedModuleSpec { - - #ifdef RCT_NEW_ARCH_ENABLED - bool isThereAnyLayoutProp(jsi::Runtime &rt, const jsi::Object &props); -+ jsi::Object getUIProps(jsi::Runtime &rt, const jsi::Object &props); - jsi::Value filterNonAnimatableProps( - jsi::Runtime &rt, - const jsi::Value &props); -diff --git a/node_modules/react-native-reanimated/apple/REANodesManager.mm b/node_modules/react-native-reanimated/apple/REANodesManager.mm -index ed36c99..0c64925 100644 ---- a/node_modules/react-native-reanimated/apple/REANodesManager.mm -+++ b/node_modules/react-native-reanimated/apple/REANodesManager.mm -@@ -432,9 +432,9 @@ - (void)synchronouslyUpdateViewOnUIThread:(nonnull NSNumber *)viewTag props:(non - REAUIView *componentView = - [componentViewRegistry findComponentViewWithTag:[viewTag integerValue]]; - -- NSSet *propKeysManagedByAnimated = [componentView propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN]; -+ // NSSet *propKeysManagedByAnimated = [componentView propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN]; - [surfacePresenter synchronouslyUpdateViewOnUIThread:viewTag props:uiProps]; -- [componentView setPropKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN:propKeysManagedByAnimated]; -+ // [componentView setPropKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN:propKeysManagedByAnimated]; - - // `synchronouslyUpdateViewOnUIThread` does not flush props like `backgroundColor` etc. - // so that's why we need to call `finalizeUpdates` here. From f57c4f8ba275f02fbdfd6d2a8ceae6cc842c3f12 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Tue, 3 Sep 2024 15:16:56 +0200 Subject: [PATCH 029/593] bump react-native-keyboard-controller --- ios/Podfile.lock | 4 ++-- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d688213fafb4..b66b25716382 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1689,7 +1689,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-keyboard-controller (1.12.2): + - react-native-keyboard-controller (1.13.3): - DoubleConversion - glog - hermes-engine @@ -3184,7 +3184,7 @@ SPEC CHECKSUMS: react-native-geolocation: b9bd12beaf0ebca61a01514517ca8455bd26fa06 react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440 react-native-key-command: aae312752fcdfaa2240be9a015fc41ce54087546 - react-native-keyboard-controller: 5075321af7b1c834cfb9582230659d032c963278 + react-native-keyboard-controller: ee7d85b59a4555075b5050eab29bda0aadd6791f react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: fb5112b1fa754975485884ae85a3fb6a684f49d5 react-native-pager-view: 6bff9b0883b902571530ddd1b2ea9dc570f321f6 diff --git a/package-lock.json b/package-lock.json index f01231311246..a60f8e009583 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,7 +98,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#93399c6410de32966eb57085936ef6951398c2c3", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.13.2", + "react-native-keyboard-controller": "^1.13.3", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", @@ -37360,9 +37360,9 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.13.2.tgz", - "integrity": "sha512-FZkxByqboqa2bq2fXtEnD7f78VmKbu5cHjEfubHfV2ZtkGolZ01XTqKkEQ172GvFSjC5iuF1L3h7C4g8R6Xq9Q==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.13.3.tgz", + "integrity": "sha512-C6W0Ta5cCKa58pTL3A8WPFNvDgwc5+Qs3pj4v3Q4Emk1INkUEkJDsWyV7HdR232V/98mLSWai2W/8HlqsOAqhQ==", "peerDependencies": { "react": "*", "react-native": "*", diff --git a/package.json b/package.json index e34f1430e29c..8a47fe4d7de8 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#93399c6410de32966eb57085936ef6951398c2c3", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.13.2", + "react-native-keyboard-controller": "^1.13.3", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", From e042032b043454a80cd5b5340c741b420c96b84d Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Tue, 3 Sep 2024 15:17:26 +0200 Subject: [PATCH 030/593] remove patch react-native-keyboard-controller --- ...keyboard-controller+1.12.2+002+rn-75-fixes.patch | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 patches/react-native-keyboard-controller+1.12.2+002+rn-75-fixes.patch diff --git a/patches/react-native-keyboard-controller+1.12.2+002+rn-75-fixes.patch b/patches/react-native-keyboard-controller+1.12.2+002+rn-75-fixes.patch deleted file mode 100644 index f7ab542a2a2b..000000000000 --- a/patches/react-native-keyboard-controller+1.12.2+002+rn-75-fixes.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt -index 50252f0..28a70d6 100644 ---- a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt -+++ b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/extensions/ThemedReactContext.kt -@@ -13,7 +13,7 @@ val ThemedReactContext.rootView: View? - - fun ThemedReactContext?.dispatchEvent(viewId: Int, event: Event<*>) { - val eventDispatcher: EventDispatcher? = -- UIManagerHelper.getEventDispatcherForReactTag(this, viewId) -+ UIManagerHelper.getEventDispatcherForReactTag(this!!, viewId) - eventDispatcher?.dispatchEvent(event) - } - From c0c2e52886ea8da7cf862a3d8d4fb4e97d3c5299 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Mon, 9 Sep 2024 11:50:29 +0200 Subject: [PATCH 031/593] fix firebase traceMap error --- src/libs/Firebase/index.native.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Firebase/index.native.ts b/src/libs/Firebase/index.native.ts index d2746d8b25e7..0af52eefb58c 100644 --- a/src/libs/Firebase/index.native.ts +++ b/src/libs/Firebase/index.native.ts @@ -41,7 +41,7 @@ const stopTrace: StopTrace = (customEventName) => { return; } - const trace = traceMap[customEventName].trace; + const trace = traceMap[customEventName]?.trace; if (!trace) { return; } From 88030516257f020476e377766e2f31ffa17b8b7f Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Wed, 11 Sep 2024 10:30:15 +0200 Subject: [PATCH 032/593] bump react-native-keyboard-controller --- ios/Podfile.lock | 4 ++-- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b119e04c3ae5..79cae5374b71 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1689,7 +1689,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-keyboard-controller (1.13.3): + - react-native-keyboard-controller (1.13.4): - DoubleConversion - glog - hermes-engine @@ -3184,7 +3184,7 @@ SPEC CHECKSUMS: react-native-geolocation: b9bd12beaf0ebca61a01514517ca8455bd26fa06 react-native-image-picker: f8a13ff106bcc7eb00c71ce11fdc36aac2a44440 react-native-key-command: aae312752fcdfaa2240be9a015fc41ce54087546 - react-native-keyboard-controller: ee7d85b59a4555075b5050eab29bda0aadd6791f + react-native-keyboard-controller: 56b8c30d8ba0eb27b406eec79799d870d95e9046 react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d react-native-netinfo: fb5112b1fa754975485884ae85a3fb6a684f49d5 react-native-pager-view: 94195f1bf32e7f78359fa20057c97e632364a08b diff --git a/package-lock.json b/package-lock.json index 6139b904e62e..9166c7e16e98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,7 +98,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.13.3", + "react-native-keyboard-controller": "^1.13.4", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", @@ -37352,9 +37352,9 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.13.3.tgz", - "integrity": "sha512-C6W0Ta5cCKa58pTL3A8WPFNvDgwc5+Qs3pj4v3Q4Emk1INkUEkJDsWyV7HdR232V/98mLSWai2W/8HlqsOAqhQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.13.4.tgz", + "integrity": "sha512-80unzD4S+ybgYOluhdeV4zV62ejg6Jt0l5iw7PuA1y3aM1a1+5tS2WoHLNk8605oDaGcVLGNMNF0Qv4GWe97Bg==", "peerDependencies": { "react": "*", "react-native": "*", diff --git a/package.json b/package.json index d2ac50e1dbe5..44e592363302 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,7 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "^1.13.3", + "react-native-keyboard-controller": "^1.13.4", "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", From 808d75f277359610b8614970921bbca841c1fa96 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Wed, 11 Sep 2024 10:56:20 +0200 Subject: [PATCH 033/593] left only context menu transition: clean components --- .../BaseAnchorForAttachmentsOnly.tsx | 6 ++--- .../AttachmentPicker/index.native.tsx | 3 +-- src/components/AttachmentPicker/types.ts | 6 ----- src/components/ConfirmContent.tsx | 23 +++------------- .../EmojiPicker/EmojiPickerButton.tsx | 16 +---------- .../EmojiPickerMenu/index.native.tsx | 22 ++------------- .../Reactions/AddReactionBubble.tsx | 3 +-- .../QuickEmojiReactions/index.native.tsx | 26 ++---------------- .../ReportActionItemEmojiReactions.tsx | 11 -------- .../report/ContextMenu/ContextMenuActions.tsx | 21 +++------------ .../AttachmentPickerWithMenuItems.tsx | 18 +------------ src/pages/home/report/ReportActionItem.tsx | 27 ------------------- 12 files changed, 16 insertions(+), 166 deletions(-) diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx index 6e2ced803501..1d273e847d26 100644 --- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx +++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx @@ -41,7 +41,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow return ( - {({onShowContextMenu, anchor, report, reportNameValuePairs, action, checkIfContextMenuActive}) => ( + {({anchor, report, reportNameValuePairs, action, checkIfContextMenuActive}) => ( { @@ -54,9 +54,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow onPressIn={onPressIn} onPressOut={onPressOut} onLongPress={(event) => - onShowContextMenu(() => - showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)), - ) + showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)) } shouldUseHapticsOnLongPress accessibilityLabel={displayName} diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 68cc7458728c..366366423324 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -112,7 +112,7 @@ const getDataForUpload = (fileData: FileResponse): Promise => { * a callback. This is the ios/android implementation * opening a modal with attachment options */ -function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, shouldHideCameraOption = false, onLayout}: AttachmentPickerProps) { +function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, shouldHideCameraOption = false}: AttachmentPickerProps) { const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); @@ -382,7 +382,6 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s return ( <> { close(); onCanceled.current(); diff --git a/src/components/AttachmentPicker/types.ts b/src/components/AttachmentPicker/types.ts index 7ef0e1120afd..63ed27408eea 100644 --- a/src/components/AttachmentPicker/types.ts +++ b/src/components/AttachmentPicker/types.ts @@ -42,12 +42,6 @@ type AttachmentPickerProps = { /** The types of files that can be selected with this picker. */ type?: ValueOf; - /** - * Optional callback attached to popover's children container. - * Invoked on Popover mount and layout changes. - */ - onLayout?: ((event: LayoutChangeEvent) => void) | undefined; - acceptedFileTypes?: Array>; }; diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index b3bbf891bf06..a75179dd3831 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -1,6 +1,6 @@ import type {ReactNode} from 'react'; -import React, {useCallback, useContext} from 'react'; -import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import React, {useContext} from 'react'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -132,20 +132,6 @@ function ConfirmContent({ const {isOffline} = useNetwork(); const StyleUtils = useStyleUtils(); - const onLayout = useCallback( - (event: LayoutChangeEvent) => { - const {height} = event.nativeEvent.layout; - - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: Actions.MEASURE_CONFIRM_MODAL, - payload: { - popoverHeight: height, - }, - }); - }, - [actionSheetAwareScrollViewContext], - ); - const isCentered = shouldCenterContent; return ( @@ -162,10 +148,7 @@ function ConfirmContent({ )} - + {shouldShowDismissIcon && ( diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index 00389e3b9e10..a0dffa3486ee 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -43,23 +43,9 @@ function EmojiPickerButton({isDisabled = false, id = '', emojiPickerID = '', shi return; } - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.OPEN_EMOJI_PICKER_POPOVER_STANDALONE, - }); - - const onHide = () => { - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER_STANDALONE, - }); - - if (onModalHide) { - onModalHide(); - } - }; - if (!EmojiPickerAction.emojiPickerRef.current?.isEmojiPickerVisible) { EmojiPickerAction.showEmojiPicker( - onHide, + onModalHide, onEmojiSelected, emojiPopoverAnchor, { diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index 41fac94e9e7d..b5b4c2d7e71c 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -1,11 +1,9 @@ import type {ListRenderItem} from '@shopify/flash-list'; import lodashDebounce from 'lodash/debounce'; -import React, {useCallback, useContext} from 'react'; +import React, {useCallback} from 'react'; import type {ForwardedRef} from 'react'; -import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import {runOnUI, scrollTo} from 'react-native-reanimated'; -import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -116,25 +114,9 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r }, [styles, windowWidth, preferredSkinTone, singleExecution, onEmojiSelected, translate, activeEmoji], ); - const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); - const onLayout = useCallback( - (event: LayoutChangeEvent) => { - const {height} = event.nativeEvent.layout; - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.MEASURE_EMOJI_PICKER_POPOVER, - payload: { - popoverHeight: height, - }, - }); - }, - [actionSheetAwareScrollViewContext], - ); return ( - + EmojiPickerAction.resetEmojiPopoverAnchor, []); const onPress = () => { - const openPicker = (refParam?: PickerRefElement, anchorOrigin?: AnchorOrigin, onHide = () => {}) => { + const openPicker = (refParam?: PickerRefElement, anchorOrigin?: AnchorOrigin) => { EmojiPickerAction.showEmojiPicker( () => { - onHide(); setIsEmojiPickerActive?.(false); }, (emojiCode, emojiObject) => { diff --git a/src/components/Reactions/QuickEmojiReactions/index.native.tsx b/src/components/Reactions/QuickEmojiReactions/index.native.tsx index 6c55beb9741d..28e2125e1c40 100644 --- a/src/components/Reactions/QuickEmojiReactions/index.native.tsx +++ b/src/components/Reactions/QuickEmojiReactions/index.native.tsx @@ -1,17 +1,10 @@ -import React, {useContext} from 'react'; -import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; +import React from 'react'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import BaseQuickEmojiReactions from './BaseQuickEmojiReactions'; -import type {BaseQuickEmojiReactionsProps, OpenPickerCallback, QuickEmojiReactionsProps} from './types'; +import type {OpenPickerCallback, QuickEmojiReactionsProps} from './types'; function QuickEmojiReactions({closeContextMenu, onEmojiSelected, ...rest}: QuickEmojiReactionsProps) { - const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); - const onPressOpenPicker = (openPicker?: OpenPickerCallback) => { - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.OPEN_EMOJI_PICKER_POPOVER, - }); - // We first need to close the menu as it's a popover. // The picker is a popover as well and on mobile there can only // be one active popover at a time. @@ -20,28 +13,13 @@ function QuickEmojiReactions({closeContextMenu, onEmojiSelected, ...rest}: Quick // gets closed, before the picker actually opens, we pass the composer // ref as anchor for the emoji picker popover. openPicker?.(ReportActionComposeFocusManager.composerRef); - - openPicker?.(ReportActionComposeFocusManager.composerRef, undefined, () => { - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER, - }); - }); }); }; - const onEmojiSelectedCallback: BaseQuickEmojiReactionsProps['onEmojiSelected'] = (emoji, emojiReactions) => { - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER, - }); - - onEmojiSelected(emoji, emojiReactions); - }; - return ( ); diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.tsx b/src/components/Reactions/ReportActionItemEmojiReactions.tsx index 605e7ea4bb77..fe683f51aa15 100644 --- a/src/components/Reactions/ReportActionItemEmojiReactions.tsx +++ b/src/components/Reactions/ReportActionItemEmojiReactions.tsx @@ -36,15 +36,6 @@ type ReportActionItemEmojiReactionsProps = WithCurrentUserPersonalDetailsProps & */ toggleReaction: (emoji: Emoji, ignoreSkinToneOnCompare?: boolean) => void; - /** - * Function to call when the user presses on the add reaction button. - * This is only called when the user presses on the button, not on the - * reaction bubbles. - * This is optional, because we don't need it everywhere. - * For example in the ReportActionContextMenu we don't need it. - */ - onPressOpenPicker: (openPicker: OpenPickerCallback) => void; - /** We disable reacting with emojis on report actions that have errors */ shouldBlockReactions?: boolean; @@ -89,7 +80,6 @@ function ReportActionItemEmojiReactions({ reportAction, currentUserPersonalDetails, toggleReaction, - onPressOpenPicker, emojiReactions = {}, shouldBlockReactions = false, preferredLocale = CONST.LOCALES.DEFAULT, @@ -181,7 +171,6 @@ function ReportActionItemEmojiReactions({ })} {!shouldBlockReactions && ( type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && ReportUtils.canEditReportAction(reportAction) && !isArchivedRoom && !isChronosReport, - onPress: (closePopover, {reportID, reportAction, draftMessage, transitionActionSheetState}) => { + onPress: (closePopover, {reportID, reportAction, draftMessage}) => { if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { hideContextMenu(false); const childReportID = reportAction?.childReportID ?? '-1'; @@ -255,10 +255,6 @@ const ContextMenuActions: ContextMenuAction[] = [ }; if (closePopover) { - transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.EDIT_REPORT, - }); - // Hide popover, then call editAction hideContextMenu(false, editAction); return; @@ -622,21 +618,10 @@ const ContextMenuActions: ContextMenuAction[] = [ !isArchivedRoom && !isChronosReport && !ReportActionsUtils.isMessageDeleted(reportAction), - onPress: (closePopover, {reportID, reportAction, transitionActionSheetState}) => { + onPress: (closePopover, {reportID, reportAction}) => { if (closePopover) { - transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.SHOW_DELETE_CONFIRM_MODAL, - }); - - const onClose = () => { - transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.CLOSE_CONFIRM_MODAL, - }); - clearActiveReportAction(); - }; - // Hide popover, then call showDeleteConfirmModal - hideContextMenu(false, () => showDeleteModal(reportID, reportAction, true, onClose, onClose)); + hideContextMenu(false, () => showDeleteModal(reportID, reportAction)); return; } diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index dd394a65911f..6e3c3a48de74 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -1,10 +1,8 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useContext, useEffect, useMemo} from 'react'; -import type {LayoutChangeEvent} from 'react-native'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import type {FileObject} from '@components/AttachmentModal'; import AttachmentPicker from '@components/AttachmentPicker'; import Icon from '@components/Icon'; @@ -117,7 +115,6 @@ function AttachmentPickerWithMenuItems({ actionButtonRef, raiseIsScrollLikelyLayoutTriggered, }: AttachmentPickerWithMenuItemsProps) { - const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const isFocused = useIsFocused(); const theme = useTheme(); const styles = useThemeStyles(); @@ -188,18 +185,6 @@ function AttachmentPickerWithMenuItems({ ]; }, [report, reportID, translate]); - const measurePopover = useCallback( - ({nativeEvent}: LayoutChangeEvent) => { - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.MEASURE_POPOVER, - payload: { - popoverHeight: nativeEvent.layout.height, - }, - }); - }, - [actionSheetAwareScrollViewContext], - ); - const onPopoverMenuClose = () => { setMenuVisibility(false); onMenuClosed(); @@ -322,7 +307,6 @@ function AttachmentPickerWithMenuItems({ { - if (!(popoverAnchorRef.current && 'measureInWindow' in popoverAnchorRef.current)) { - return; - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - popoverAnchorRef.current.measureInWindow((_fx, fy, _width, height) => { - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.OPEN_EMOJI_PICKER_POPOVER, - payload: { - fy, - height, - }, - }); - - openPicker(undefined, undefined, () => { - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: ActionSheetAwareScrollView.Actions.CLOSE_EMOJI_PICKER_POPOVER, - }); - }); - }); - }, - [actionSheetAwareScrollViewContext], - ); - const handleShowContextMenu = useCallback( (callback: () => void) => { if (!(popoverAnchorRef.current && 'measureInWindow' in popoverAnchorRef.current)) { @@ -819,7 +793,6 @@ function ReportActionItem({ {!ReportActionsUtils.isMessageDeleted(action) && ( Date: Wed, 11 Sep 2024 11:23:35 +0200 Subject: [PATCH 034/593] left only context menu transition: clean actions --- .../ActionSheetAwareScrollViewContext.tsx | 82 ------------ .../ActionSheetKeyboardSpace.tsx | 126 ++---------------- 2 files changed, 9 insertions(+), 199 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx index 6f0dad2b1b19..ded1719fcb35 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -51,22 +51,9 @@ const Actions = { MEASURE_POPOVER: 'MEASURE_POPOVER', MEASURE_COMPOSER: 'MEASURE_COMPOSER', POPOVER_ANY_ACTION: 'POPOVER_ANY_ACTION', - OPEN_EMOJI_PICKER_POPOVER: 'OPEN_EMOJI_PICKER_POPOVER', - OPEN_EMOJI_PICKER_POPOVER_STANDALONE: 'OPEN_EMOJI_PICKER_POPOVER_STANDALONE', - CLOSE_EMOJI_PICKER_POPOVER_STANDALONE: 'CLOSE_EMOJI_PICKER_POPOVER_STANDALONE', - CLOSE_EMOJI_PICKER_POPOVER: 'CLOSE_EMOJI_PICKER_POPOVER', - MEASURE_EMOJI_PICKER_POPOVER: 'MEASURE_EMOJI_PICKER_POPOVER', HIDE_WITHOUT_ANIMATION: 'HIDE_WITHOUT_ANIMATION', - EDIT_REPORT: 'EDIT_REPORT', - SHOW_DELETE_CONFIRM_MODAL: 'SHOW_DELETE_CONFIRM_MODAL', END_TRANSITION: 'END_TRANSITION', OPEN_CALL_POPOVER: 'OPEN_CALL_POPOVER', - CLOSE_CONFIRM_MODAL: 'CLOSE_CONFIRM_MODAL', - MEASURE_CONFIRM_MODAL: 'MEASURE_CONFIRM_MODAL', - SHOW_ATTACHMENTS_POPOVER: 'SHOW_ATTACHMENTS_POPOVER', - CLOSE_ATTACHMENTS_POPOVER: 'CLOSE_ATTACHMENTS_POPOVER', - SHOW_ATTACHMENTS_PICKER_POPOVER: 'SHOW_ATTACHMENTS_PICKER_POPOVER', - CLOSE_ATTACHMENTS_PICKER_POPOVER: 'CLOSE_ATTACHMENTS_PICKER_POPOVER', MEASURE_CALL_POPOVER: 'MEASURE_CALL_POPOVER', CLOSE_CALL_POPOVER: 'CLOSE_CALL_POPOVER', }; @@ -80,20 +67,9 @@ const States = { KEYBOARD_POPOVER_OPEN: 'keyboardPopoverOpen', KEYBOARD_CLOSED_POPOVER: 'keyboardClosingPopover', POPOVER_MEASURED: 'popoverMeasured', - EMOJI_PICKER_POPOVER_OPEN: 'emojiPickerPopoverOpen', - DELETE_MODAL_OPEN: 'deleteModalOpen', - DELETE_MODAL_WITH_KEYBOARD_OPEN: 'deleteModalWithKeyboardOpen', - EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN: 'emojiPickerPopoverWithKeyboardOpen', - EMOJI_PICKER_WITH_KEYBOARD_OPEN: 'emojiPickerWithKeyboardOpen', CALL_POPOVER_WITH_KEYBOARD_OPEN: 'callPopoverWithKeyboardOpen', CALL_POPOVER_WITH_KEYBOARD_CLOSED: 'callPopoverWithKeyboardClosed', - ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN: 'attachmentsPopoverWithKeyboardOpen', - ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED: 'attachmentsPopoverWithKeyboardClosed', - ATTACHMENTS_POPOVER_OPEN: 'attachmentsPopoverOpen', - ATTACHMENTS_POPOVER_CLOSED: 'attachmentsPopoverClosed', - MODAL_DELETED: 'modalDeleted', MODAL_WITH_KEYBOARD_OPEN_DELETED: 'modalWithKeyboardOpenDeleted', - EDIT_MESSAGE: 'editMessage', }; const STATE_MACHINE = { @@ -102,65 +78,28 @@ const STATE_MACHINE = { [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, [Actions.MEASURE_POPOVER]: States.IDLE, [Actions.MEASURE_COMPOSER]: States.IDLE, - [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, - [Actions.SHOW_ATTACHMENTS_PICKER_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, - [Actions.OPEN_EMOJI_PICKER_POPOVER_STANDALONE]: States.EMOJI_PICKER_POPOVER_OPEN, - [Actions.SHOW_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_OPEN, }, [States.POPOVER_OPEN]: { [Actions.CLOSE_POPOVER]: States.POPOVER_CLOSED, [Actions.MEASURE_POPOVER]: States.POPOVER_OPEN, [Actions.MEASURE_COMPOSER]: States.POPOVER_OPEN, - [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, [Actions.POPOVER_ANY_ACTION]: States.POPOVER_CLOSED, [Actions.HIDE_WITHOUT_ANIMATION]: States.IDLE, - [Actions.EDIT_REPORT]: States.IDLE, - [Actions.SHOW_DELETE_CONFIRM_MODAL]: States.MODAL_DELETED, }, [States.POPOVER_CLOSED]: { [Actions.END_TRANSITION]: States.IDLE, }, - [States.EMOJI_PICKER_POPOVER_OPEN]: { - [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, - [Actions.CLOSE_EMOJI_PICKER_POPOVER]: States.POPOVER_CLOSED, - [Actions.CLOSE_EMOJI_PICKER_POPOVER_STANDALONE]: States.POPOVER_CLOSED, - }, - [States.MODAL_DELETED]: { - [Actions.MEASURE_CONFIRM_MODAL]: States.MODAL_DELETED, - [Actions.CLOSE_CONFIRM_MODAL]: States.POPOVER_CLOSED, - }, [States.KEYBOARD_OPEN]: { [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, [Actions.OPEN_POPOVER]: States.KEYBOARD_POPOVER_OPEN, - [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.KEYBOARD_POPOVER_OPEN, - [Actions.OPEN_EMOJI_PICKER_POPOVER_STANDALONE]: States.EMOJI_PICKER_WITH_KEYBOARD_OPEN, [Actions.CLOSE_KEYBOARD]: States.IDLE, [Actions.OPEN_CALL_POPOVER]: States.CALL_POPOVER_WITH_KEYBOARD_OPEN, - [Actions.SHOW_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, - [Actions.SHOW_ATTACHMENTS_PICKER_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, [Actions.MEASURE_COMPOSER]: States.KEYBOARD_OPEN, }, [States.KEYBOARD_POPOVER_OPEN]: { [Actions.MEASURE_POPOVER]: States.KEYBOARD_POPOVER_OPEN, [Actions.MEASURE_COMPOSER]: States.KEYBOARD_POPOVER_OPEN, [Actions.CLOSE_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, - [Actions.CLOSE_EMOJI_PICKER_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, - [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.KEYBOARD_POPOVER_OPEN, - [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN, - [Actions.SHOW_DELETE_CONFIRM_MODAL]: States.MODAL_WITH_KEYBOARD_OPEN_DELETED, - [Actions.EDIT_REPORT]: States.EDIT_MESSAGE, - }, - [States.MODAL_WITH_KEYBOARD_OPEN_DELETED]: { - [Actions.MEASURE_CONFIRM_MODAL]: States.MODAL_WITH_KEYBOARD_OPEN_DELETED, - [Actions.CLOSE_CONFIRM_MODAL]: States.KEYBOARD_CLOSED_POPOVER, - }, - [States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN]: { - [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN, - [Actions.CLOSE_EMOJI_PICKER_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, - }, - [States.EMOJI_PICKER_WITH_KEYBOARD_OPEN]: { - [Actions.MEASURE_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_WITH_KEYBOARD_OPEN, - [Actions.CLOSE_EMOJI_PICKER_POPOVER_STANDALONE]: States.KEYBOARD_POPOVER_CLOSED, }, [States.CALL_POPOVER_WITH_KEYBOARD_OPEN]: { [Actions.MEASURE_POPOVER]: States.CALL_POPOVER_WITH_KEYBOARD_OPEN, @@ -170,24 +109,6 @@ const STATE_MACHINE = { [States.CALL_POPOVER_WITH_KEYBOARD_CLOSED]: { [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, }, - [States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN]: { - [Actions.MEASURE_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, - [Actions.MEASURE_COMPOSER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN, - [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED, - }, - [States.ATTACHMENTS_POPOVER_OPEN]: { - [Actions.MEASURE_POPOVER]: States.ATTACHMENTS_POPOVER_OPEN, - [Actions.MEASURE_COMPOSER]: States.ATTACHMENTS_POPOVER_OPEN, - [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.POPOVER_CLOSED, - }, - [States.ATTACHMENTS_POPOVER_CLOSED]: { - [Actions.CLOSE_ATTACHMENTS_POPOVER]: States.ATTACHMENTS_POPOVER_CLOSED, - [Actions.OPEN_EMOJI_PICKER_POPOVER]: States.EMOJI_PICKER_POPOVER_OPEN, - }, - - [States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED]: { - [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, - }, [States.KEYBOARD_POPOVER_CLOSED]: { [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, }, @@ -195,9 +116,6 @@ const STATE_MACHINE = { [Actions.OPEN_KEYBOARD]: States.KEYBOARD_OPEN, [Actions.END_TRANSITION]: States.KEYBOARD_OPEN, }, - [States.EDIT_MESSAGE]: { - [Actions.CLOSE_KEYBOARD]: States.IDLE, - }, }; function ActionSheetAwareScrollViewProvider(props: PropsWithChildren) { diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index 68a0e91caa48..e69732ef410a 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -32,7 +32,6 @@ const useAnimatedKeyboard = () => { { onStart: (e) => { 'worklet'; - // save the last keyboard height if (e.height === 0) { heightWhenOpened.value = height.value; @@ -47,7 +46,6 @@ const useAnimatedKeyboard = () => { }, onEnd: (e) => { 'worklet'; - state.value = e.height > 0 ? KeyboardState.OPEN : KeyboardState.CLOSED; height.value = e.height; progress.value = e.progress; @@ -59,12 +57,6 @@ const useAnimatedKeyboard = () => { return {state, height, heightWhenOpened, progress}; }; -const setInitialValueAndRunAnimation = (value: number, animation: number) => { - 'worklet'; - - return withSequence(withTiming(value, {duration: 0}), animation); -}; - const useSafeAreaPaddings = () => { const StyleUtils = useStyleUtils(); const insets = useSafeAreaInsets(); @@ -95,10 +87,11 @@ function ActionSheetKeyboardSpace(props: ViewProps) { useAnimatedReaction( () => keyboard.state.value, (lastState) => { - if (lastState === syncLocalWorkletState.lastState) { - return; - } - + if (lastState === syncLocalWorkletState.lastState) { + return; + } + syncLocalWorkletState.lastState = lastState; + if (lastState === KeyboardState.OPEN) { runOnJS(transitionActionSheetState)({type: Actions.OPEN_KEYBOARD}); } else if (lastState === KeyboardState.CLOSED) { @@ -132,14 +125,15 @@ function ActionSheetKeyboardSpace(props: ViewProps) { // Depending on the current and sometimes previous state we can return // either animation or just a value - switch (current.state) { case States.KEYBOARD_OPEN: { - if (previous.state === States.KEYBOARD_CLOSED_POPOVER || (previous.state === States.KEYBOARD_OPEN && elementOffset < 0)) { + if (previous.state === States.KEYBOARD_CLOSED_POPOVER || (previous.state === States.KEYBOARD_OPEN && elementOffset < 0)) { + console.log(110, Math.max(keyboard.heightWhenOpened.value - keyboard.height.value - safeArea.bottom, 0) + Math.max(elementOffset, 0)); + console.log(1101, previous.state === States.KEYBOARD_CLOSED_POPOVER, previous.state === States.KEYBOARD_OPEN, elementOffset); return Math.max(keyboard.heightWhenOpened.value - keyboard.height.value - safeArea.bottom, 0) + Math.max(elementOffset, 0); } - console.log(111, 0); + console.log(111, 0, previous.state === States.KEYBOARD_CLOSED_POPOVER, previous.state === States.KEYBOARD_OPEN, elementOffset); return withSpring(0, SPRING_CONFIG); } @@ -152,7 +146,6 @@ function ActionSheetKeyboardSpace(props: ViewProps) { }); } - case States.MODAL_DELETED: case States.POPOVER_OPEN: { if (popoverHeight) { if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { @@ -167,103 +160,6 @@ function ActionSheetKeyboardSpace(props: ViewProps) { console.log(115, 0); return 0; } - case States.ATTACHMENTS_POPOVER_OPEN: - case States.EMOJI_PICKER_POPOVER_OPEN: { - if (popoverHeight) { - if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { - console.log(98, elementOffset < 0 ? 0 : elementOffset, elementOffset); - return withSpring(elementOffset < 0 ? 0 : elementOffset, SPRING_CONFIG); - } - - console.log(99, Math.max(previousElementOffset, 0), previousElementOffset, popoverHeight - composerHeight); - return withSpring(popoverHeight - composerHeight, SPRING_CONFIG); - } - - console.log(1100, 0); - return 0; - } - - case States.MODAL_WITH_KEYBOARD_OPEN_DELETED: - case States.EMOJI_PICKER_POPOVER_WITH_KEYBOARD_OPEN: { - // when item is higher than keyboard and bottom sheet - // we should just stay in place - if (elementOffset < 0) { - console.log(116, invertedKeyboardHeight); - return invertedKeyboardHeight; - } - - const nextOffset = invertedKeyboardHeight + elementOffset; - if (previous?.payload?.popoverHeight !== popoverHeight) { - const previousOffset = invertedKeyboardHeight + previousElementOffset; - - if (previousElementOffset === 0 || nextOffset > previousOffset) { - console.log(117, nextOffset); - return withSpring(nextOffset, SPRING_CONFIG); - } - - console.log(118, previousOffset); - return previousOffset; - } - console.log(119, nextOffset); - return nextOffset; - } - - case States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_CLOSED: - case States.ATTACHMENTS_POPOVER_WITH_KEYBOARD_OPEN: { - // this transition is extremely slow and we may not have `popoverHeight` when keyboard is hiding - // so we run two fold animation: - // - when keyboard is hiding -> we return `0` and thus the content is sticky to composer - // - when keyboard is closed and we have `popoverHeight` (i. e. popup was measured) -> we run spring animation - if (keyboard.state.value === KeyboardState.CLOSING) { - console.log(1200, 0); - return 0; - } - if (keyboard.progress.value === 0) { - console.log(1201, keyboard.progress.value, interpolate(keyboard.progress.value, [0, 1], [popoverHeight - composerHeight, 0]), popoverHeight, composerHeight); - return withSpring(popoverHeight - composerHeight, SPRING_CONFIG); - } - - // when keyboard appears -> we already have all values so we do interpolation based on keyboard position - console.log(1202, keyboard.progress.value, interpolate(keyboard.progress.value, [0, 1], [popoverHeight - composerHeight, 0]), popoverHeight, composerHeight); - return interpolate(keyboard.progress.value, [0, 1], [popoverHeight - composerHeight, 0]); - } - case States.CALL_POPOVER_WITH_KEYBOARD_OPEN: { - if (keyboard.height.value > 0) { - console.log(121, 0); - return 0; - } - console.log(122, lastKeyboardHeight, popoverHeight - composerHeight); - return setInitialValueAndRunAnimation(lastKeyboardHeight, withSpring(popoverHeight - composerHeight, SPRING_CONFIG)); - } - case States.CALL_POPOVER_WITH_KEYBOARD_CLOSED: { - // keyboard is opened - if (keyboard.height.value > 0) { - console.log(123, 0); - return 0; - } - - console.log(124, lastKeyboardHeight); - return withSpring(lastKeyboardHeight, SPRING_CONFIG); - } - case States.EMOJI_PICKER_WITH_KEYBOARD_OPEN: { - if (keyboard.state.value === KeyboardState.CLOSED) { - console.log(125, popoverHeight - composerHeight); - return popoverHeight - composerHeight; - } - - console.log(126, 0); - return Math.max(keyboard.heightWhenOpened.value - keyboard.height.value - safeArea.bottom, 0); - } - - case States.KEYBOARD_POPOVER_CLOSED: { - if (keyboard.heightWhenOpened.value === keyboard.height.value) { - console.log(127, 0); - return 0; - } - - console.log(128, popoverHeight - composerHeight); - return Math.max(keyboard.heightWhenOpened.value - keyboard.height.value - safeArea.bottom, 0); - } case States.KEYBOARD_POPOVER_OPEN: { if (keyboard.state.value === KeyboardState.OPEN) { @@ -310,10 +206,6 @@ function ActionSheetKeyboardSpace(props: ViewProps) { duration: 0, }); } - case States.EDIT_MESSAGE: { - console.log(137, 0); - return 0; - } default: console.log(138, 0); From 2427bbe33e3a07cfeea8672cbc171ded8a3c304d Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Wed, 11 Sep 2024 11:32:40 +0200 Subject: [PATCH 035/593] left only context menu transition: the rest --- src/components/ConfirmContent.tsx | 4 +--- src/components/EmojiPicker/EmojiPickerButton.tsx | 4 +--- src/components/Reactions/QuickEmojiReactions/index.native.tsx | 2 +- src/components/Reactions/QuickEmojiReactions/types.ts | 2 +- src/components/Reactions/ReportActionItemEmojiReactions.tsx | 1 - .../home/report/ReportActionCompose/ReportActionCompose.tsx | 1 - 6 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index a75179dd3831..36f24c2a3477 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -1,5 +1,5 @@ import type {ReactNode} from 'react'; -import React, {useContext} from 'react'; +import React from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; @@ -11,7 +11,6 @@ import colors from '@styles/theme/colors'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; -import {Actions, ActionSheetAwareScrollViewContext} from './ActionSheetAwareScrollView'; import Button from './Button'; import Header from './Header'; import Icon from './Icon'; @@ -125,7 +124,6 @@ function ConfirmContent({ titleContainerStyles, shouldReverseStackedButtons = false, }: ConfirmContentProps) { - const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const {translate} = useLocalize(); const theme = useTheme(); diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index a0dffa3486ee..466cfe0dffd5 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -1,6 +1,5 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {memo, useContext, useEffect, useRef} from 'react'; -import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; +import React, {memo, useEffect, useRef} from 'react'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -31,7 +30,6 @@ type EmojiPickerButtonProps = { }; function EmojiPickerButton({isDisabled = false, id = '', emojiPickerID = '', shiftVertical = 0, onModalHide, onEmojiSelected}: EmojiPickerButtonProps) { - const actionSheetAwareScrollViewContext = useContext(ActionSheetAwareScrollView.ActionSheetAwareScrollViewContext); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const emojiPopoverAnchor = useRef(null); diff --git a/src/components/Reactions/QuickEmojiReactions/index.native.tsx b/src/components/Reactions/QuickEmojiReactions/index.native.tsx index 28e2125e1c40..b0eb88b31b68 100644 --- a/src/components/Reactions/QuickEmojiReactions/index.native.tsx +++ b/src/components/Reactions/QuickEmojiReactions/index.native.tsx @@ -3,7 +3,7 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import BaseQuickEmojiReactions from './BaseQuickEmojiReactions'; import type {OpenPickerCallback, QuickEmojiReactionsProps} from './types'; -function QuickEmojiReactions({closeContextMenu, onEmojiSelected, ...rest}: QuickEmojiReactionsProps) { +function QuickEmojiReactions({closeContextMenu, ...rest}: QuickEmojiReactionsProps) { const onPressOpenPicker = (openPicker?: OpenPickerCallback) => { // We first need to close the menu as it's a popover. // The picker is a popover as well and on mobile there can only diff --git a/src/components/Reactions/QuickEmojiReactions/types.ts b/src/components/Reactions/QuickEmojiReactions/types.ts index 725b5aea764f..0021f33ce2c0 100644 --- a/src/components/Reactions/QuickEmojiReactions/types.ts +++ b/src/components/Reactions/QuickEmojiReactions/types.ts @@ -7,7 +7,7 @@ import type {Locale, ReportAction, ReportActionReactions} from '@src/types/onyx' type PickerRefElement = RefObject; -type OpenPickerCallback = (element?: PickerRefElement, anchorOrigin?: AnchorOrigin, callback?: () => void) => void; +type OpenPickerCallback = (element?: PickerRefElement, anchorOrigin?: AnchorOrigin) => void; type CloseContextMenuCallback = () => void; diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.tsx b/src/components/Reactions/ReportActionItemEmojiReactions.tsx index fe683f51aa15..943158607db4 100644 --- a/src/components/Reactions/ReportActionItemEmojiReactions.tsx +++ b/src/components/Reactions/ReportActionItemEmojiReactions.tsx @@ -16,7 +16,6 @@ import type {Locale, ReportAction, ReportActionReactions} from '@src/types/onyx' import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import AddReactionBubble from './AddReactionBubble'; import EmojiReactionBubble from './EmojiReactionBubble'; -import type {OpenPickerCallback} from './QuickEmojiReactions/types'; import ReactionTooltipContent from './ReactionTooltipContent'; type ReportActionItemEmojiReactionsProps = WithCurrentUserPersonalDetailsProps & { diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index cc4603bbd166..cae2dbeece33 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -146,7 +146,6 @@ function ReportActionCompose({ const actionButtonRef = useRef(null); const {isSmallScreenWidth, isMediumScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const {isOffline} = useNetwork(); - const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const navigation = useNavigation(); From 195d6bee112548ba8a2ecde8e9c1c1cb76195838 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Mon, 16 Sep 2024 17:40:47 +0200 Subject: [PATCH 036/593] adjust ActionSheetKeyboardSpace --- .../ActionSheetKeyboardSpace.tsx | 68 ++++++++----------- 1 file changed, 27 insertions(+), 41 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index e69732ef410a..2667bf3a8260 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -1,7 +1,7 @@ -import React, {useContext, useEffect, useRef} from 'react'; +import React, {useContext, useEffect} from 'react'; import type {ViewProps} from 'react-native'; import {useKeyboardHandler} from 'react-native-keyboard-controller'; -import Reanimated, {interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSequence, withSpring, withTiming} from 'react-native-reanimated'; +import Reanimated, {runOnJS, 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'; @@ -25,7 +25,7 @@ const SPRING_CONFIG = { const useAnimatedKeyboard = () => { const state = useSharedValue(KeyboardState.UNKNOWN); const height = useSharedValue(0); - const progress = useSharedValue(0); + const lastHeight = useSharedValue(0); const heightWhenOpened = useSharedValue(0); useKeyboardHandler( @@ -33,28 +33,28 @@ const useAnimatedKeyboard = () => { onStart: (e) => { 'worklet'; // save the last keyboard height - if (e.height === 0) { - heightWhenOpened.value = height.value; + if (e.height !== 0) { + heightWhenOpened.value = e.height; + height.value = 0; } + height.value = heightWhenOpened.value; + lastHeight.value = e.height; state.value = e.height > 0 ? KeyboardState.OPENING : KeyboardState.CLOSING; }, onMove: (e) => { 'worklet'; - - progress.value = e.progress; height.value = e.height; }, onEnd: (e) => { 'worklet'; state.value = e.height > 0 ? KeyboardState.OPEN : KeyboardState.CLOSED; height.value = e.height; - progress.value = e.progress; }, }, [], ); - return {state, height, heightWhenOpened, progress}; + return {state, height, heightWhenOpened}; }; const useSafeAreaPaddings = () => { @@ -71,27 +71,24 @@ function ActionSheetKeyboardSpace(props: ViewProps) { const keyboard = useAnimatedKeyboard(); // similar to using `global` in worklet but it's just a local object - const syncLocalWorkletState = useRef({ - lastState: KeyboardState.UNKNOWN, - }).current; + const syncLocalWorkletStateL = useSharedValue(KeyboardState.UNKNOWN); const {windowHeight} = useWindowDimensions(); const {currentActionSheetState, transitionActionSheetStateWorklet: transition, transitionActionSheetState, resetStateMachine} = useContext(ActionSheetAwareScrollViewContext); // Reset state machine when component unmounts - useEffect(() => () => resetStateMachine(), [resetStateMachine]); // eslint-disable-next-line arrow-body-style - // useEffect(() => { - // return () => resetStateMachine(); - // }, [resetStateMachine]); + useEffect(() => { + return () => resetStateMachine(); + }, [resetStateMachine]); useAnimatedReaction( () => keyboard.state.value, (lastState) => { - if (lastState === syncLocalWorkletState.lastState) { - return; - } - syncLocalWorkletState.lastState = lastState; - + if (lastState === syncLocalWorkletStateL.lastState) { + return; + } + syncLocalWorkletStateL.value = lastState; + if (lastState === KeyboardState.OPEN) { runOnJS(transitionActionSheetState)({type: Actions.OPEN_KEYBOARD}); } else if (lastState === KeyboardState.CLOSED) { @@ -123,22 +120,23 @@ function ActionSheetKeyboardSpace(props: ViewProps) { ? previousPayload.fy + safeArea.top + previousPayload.height - (windowHeight - previousPayload.popoverHeight) : 0; + const isOpeningKeyboard = syncLocalWorkletStateL.value === 1; + const isClosingKeyboard = syncLocalWorkletStateL.value === 3; + const isClosedKeyboard = syncLocalWorkletStateL.value === 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 (previous.state === States.KEYBOARD_CLOSED_POPOVER || (previous.state === States.KEYBOARD_OPEN && elementOffset < 0)) { - console.log(110, Math.max(keyboard.heightWhenOpened.value - keyboard.height.value - safeArea.bottom, 0) + Math.max(elementOffset, 0)); - console.log(1101, previous.state === States.KEYBOARD_CLOSED_POPOVER, previous.state === States.KEYBOARD_OPEN, elementOffset); + 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.value - keyboard.height.value - safeArea.bottom, 0) + Math.max(elementOffset, 0); } - - console.log(111, 0, previous.state === States.KEYBOARD_CLOSED_POPOVER, previous.state === States.KEYBOARD_OPEN, elementOffset); return withSpring(0, SPRING_CONFIG); } case States.POPOVER_CLOSED: { - console.log(112, 0); return withSpring(0, SPRING_CONFIG, () => { transition({ type: Actions.END_TRANSITION, @@ -149,37 +147,30 @@ function ActionSheetKeyboardSpace(props: ViewProps) { case States.POPOVER_OPEN: { if (popoverHeight) { if (previousElementOffset !== 0 || elementOffset > previousElementOffset) { - console.log(113, elementOffset < 0 ? 0 : elementOffset, elementOffset); return withSpring(elementOffset < 0 ? 0 : elementOffset, SPRING_CONFIG); } - console.log(114, Math.max(previousElementOffset, 0)); return withSpring(Math.max(previousElementOffset, 0), SPRING_CONFIG); } - console.log(115, 0); return 0; } case States.KEYBOARD_POPOVER_OPEN: { if (keyboard.state.value === KeyboardState.OPEN) { - console.log(129, 0); - return 0; + return withSpring(0, SPRING_CONFIG); } const nextOffset = elementOffset + lastKeyboardHeight; if (keyboard.state.value === KeyboardState.CLOSED && nextOffset > invertedKeyboardHeight) { - console.log(130, nextOffset < 0 ? 0 : nextOffset, nextOffset); return withSpring(nextOffset < 0 ? 0 : nextOffset, SPRING_CONFIG); } if (elementOffset < 0) { - console.log(131, lastKeyboardHeight - keyboardHeight); - return lastKeyboardHeight - keyboardHeight; + return isClosingKeyboard ? 0 : lastKeyboardHeight - keyboardHeight; } - console.log(132, lastKeyboardHeight, lastKeyboardHeight - keyboardHeight, keyboardHeight); return lastKeyboardHeight; } @@ -187,28 +178,23 @@ function ActionSheetKeyboardSpace(props: ViewProps) { if (elementOffset < 0) { transition({type: Actions.END_TRANSITION}); - console.log(133, 0); return 0; } if (keyboard.state.value === KeyboardState.CLOSED) { - console.log(134, elementOffset + lastKeyboardHeight); return elementOffset + lastKeyboardHeight; } if (keyboard.height.value > 0) { - console.log(135, keyboard.heightWhenOpened.value - keyboard.height.value + elementOffset, 'elementOffset', elementOffset); return keyboard.heightWhenOpened.value - keyboard.height.value + elementOffset; } - console.log(136, elementOffset + lastKeyboardHeight); return withTiming(elementOffset + lastKeyboardHeight, { duration: 0, }); } default: - console.log(138, 0); return 0; } }, []); From 9e25d7f232055ef17018a7e8ab989c0dc2c5f944 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Mon, 16 Sep 2024 17:41:38 +0200 Subject: [PATCH 037/593] clean STATE_MACHINE --- .../ActionSheetAwareScrollViewContext.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx index ded1719fcb35..4f56185d64da 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -98,7 +98,6 @@ const STATE_MACHINE = { }, [States.KEYBOARD_POPOVER_OPEN]: { [Actions.MEASURE_POPOVER]: States.KEYBOARD_POPOVER_OPEN, - [Actions.MEASURE_COMPOSER]: States.KEYBOARD_POPOVER_OPEN, [Actions.CLOSE_POPOVER]: States.KEYBOARD_CLOSED_POPOVER, }, [States.CALL_POPOVER_WITH_KEYBOARD_OPEN]: { From 2eb8595e9e7fa347fbe643aedbb5d557bdb00568 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Mon, 16 Sep 2024 21:23:31 +0200 Subject: [PATCH 038/593] lint --- .../ActionSheetKeyboardSpace.tsx | 7 ++++++- src/components/AttachmentPicker/types.ts | 1 - src/components/EmojiPicker/EmojiPickerButton.tsx | 2 +- .../HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx | 1 - src/components/PopoverMenu.tsx | 9 +++++---- src/hooks/useWorkletStateMachine.ts | 1 + .../report/ContextMenu/BaseReportActionContextMenu.tsx | 2 +- src/pages/home/report/ContextMenu/ContextMenuActions.tsx | 3 +-- .../report/ReportActionCompose/ReportActionCompose.tsx | 6 ------ src/pages/home/report/ReportActionItem.tsx | 1 - 10 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index 2667bf3a8260..afb4f674438e 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -32,8 +32,10 @@ const useAnimatedKeyboard = () => { { onStart: (e) => { 'worklet'; + // save the last keyboard height if (e.height !== 0) { + // eslint-disable-next-line react-compiler/react-compiler heightWhenOpened.value = e.height; height.value = 0; } @@ -43,10 +45,12 @@ const useAnimatedKeyboard = () => { }, onMove: (e) => { 'worklet'; + height.value = e.height; }, onEnd: (e) => { 'worklet'; + state.value = e.height > 0 ? KeyboardState.OPEN : KeyboardState.CLOSED; height.value = e.height; }, @@ -87,6 +91,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { if (lastState === syncLocalWorkletStateL.lastState) { return; } + // eslint-disable-next-line react-compiler/react-compiler syncLocalWorkletStateL.value = lastState; if (lastState === KeyboardState.OPEN) { @@ -110,7 +115,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { const keyboardHeight = keyboard.height.value === 0 ? 0 : keyboard.height.value - safeArea.bottom; // sometimes we need to know the last keyboard height const lastKeyboardHeight = keyboard.heightWhenOpened.value - safeArea.bottom; - const {popoverHeight = 0, fy, height, composerHeight = 0} = current.payload ?? {}; + const {popoverHeight = 0, fy, height} = current.payload ?? {}; const invertedKeyboardHeight = keyboard.state.value === KeyboardState.CLOSED ? lastKeyboardHeight : 0; const elementOffset = fy !== undefined && height !== undefined && popoverHeight !== undefined ? fy + safeArea.top + height - (windowHeight - popoverHeight) : 0; // when the sate is not idle we know for sure we have previous state diff --git a/src/components/AttachmentPicker/types.ts b/src/components/AttachmentPicker/types.ts index 63ed27408eea..057ec72de27e 100644 --- a/src/components/AttachmentPicker/types.ts +++ b/src/components/AttachmentPicker/types.ts @@ -1,5 +1,4 @@ import type {ReactNode} from 'react'; -import type {LayoutChangeEvent} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; import type CONST from '@src/CONST'; diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index 34133b88c11a..4ec90caa2e26 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -40,7 +40,7 @@ function EmojiPickerButton({isDisabled = false, id = '', emojiPickerID = '', shi const {translate} = useLocalize(); const isFocused = useIsFocused(); - const openEmojiPicker = (e) => { + const openEmojiPicker = (e: GestureResponderEvent | KeyboardEvent) => { if (!isFocused) { return; } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index 694959d1f9de..b7c428e72f29 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -40,7 +40,6 @@ function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...d onPressIn={onPressIn} onPressOut={onPressOut} onLongPress={(event) => { - console.log(111, 0); onShowContextMenu(() => { if (isDisabled) { return; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 0f9b29ecd971..b950f9b1e244 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -1,9 +1,8 @@ import lodashIsEqual from 'lodash/isEqual'; import type {RefObject} from 'react'; import React, {useLayoutEffect, useState} from 'react'; -import type {LayoutChangeEvent} from 'react-native'; +import type {LayoutChangeEvent, View} from 'react-native'; import {StyleSheet} from 'react-native'; -import type {View} from 'react-native'; import type {ModalProps} from 'react-native-modal'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -263,8 +262,10 @@ function PopoverMenu({ restoreFocusType={restoreFocusType} > - + {renderHeaderText()} {enteredSubMenuIndexes.length > 0 && renderBackButtonItem()} {currentMenuItems.map((item, menuIndex) => ( diff --git a/src/hooks/useWorkletStateMachine.ts b/src/hooks/useWorkletStateMachine.ts index b9c02faa7bd1..105814c094eb 100644 --- a/src/hooks/useWorkletStateMachine.ts +++ b/src/hooks/useWorkletStateMachine.ts @@ -146,6 +146,7 @@ function useWorkletStateMachine

(stateMachine: StateMachine, initialState: Sta 'worklet'; log('RESET STATE MACHINE'); + // eslint-disable-next-line react-compiler/react-compiler currentState.value = initialState; }, [currentState, initialState, log]); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 72975b8193ea..bc48f0b966a2 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -5,8 +5,8 @@ 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 * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import {useOnyx, withOnyx} 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'; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 9b0a2507b6a6..9dd6ca30f5be 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -6,7 +6,6 @@ import {InteractionManager} from 'react-native'; import type {GestureResponderEvent, Text, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; -import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import * as Expensicons from '@components/Icon/Expensicons'; import MiniQuickEmojiReactions from '@components/Reactions/MiniQuickEmojiReactions'; import QuickEmojiReactions from '@components/Reactions/QuickEmojiReactions'; @@ -34,7 +33,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Beta, Download as DownloadOnyx, OnyxInputOrEntry, ReportAction, ReportActionReactions, Transaction} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; -import {clearActiveReportAction, hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; +import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; import type {ContextMenuAnchor} from './ReportActionContextMenu'; /** Gets the HTML version of the message in an action */ diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index e6164bf65cc1..25b209786f0f 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -335,12 +335,6 @@ function ReportActionCompose({ [], ); - useEffect(() => { - actionSheetAwareScrollViewContext.transitionActionSheetState({ - type: isMenuVisible ? ActionSheetAwareScrollView.Actions.SHOW_ATTACHMENTS_POPOVER : ActionSheetAwareScrollView.Actions.CLOSE_ATTACHMENTS_POPOVER, - }); - }, [actionSheetAwareScrollViewContext, isMenuVisible]); - useEffect(() => { const unsubscribe = navigation.addListener('blur', () => { setShouldHideEducationalTooltip(true); diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 8af1fd33c63b..649f6d774eef 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -17,7 +17,6 @@ import KYCWall from '@components/KYCWall'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {useBlockedFromConcierge, usePersonalDetails} from '@components/OnyxProvider'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; -import type {OpenPickerCallback} from '@components/Reactions/QuickEmojiReactions/types'; import ReportActionItemEmojiReactions from '@components/Reactions/ReportActionItemEmojiReactions'; import RenderHTML from '@components/RenderHTML'; import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; From 1b5c5902d1344fcb5ba6071c39c22f49ae9f7c40 Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 21 Oct 2024 17:50:19 +0700 Subject: [PATCH 039/593] fix: 10371 auto focus input --- .../Composer/implementation/index.native.tsx | 17 +- .../Composer/implementation/index.tsx | 40 ++-- src/components/Composer/types.ts | 3 + src/pages/home/ReportScreen.tsx | 2 - .../ComposerWithSuggestions.tsx | 204 +++++++----------- .../ReportActionCompose.tsx | 13 +- src/pages/home/report/ReportFooter.tsx | 6 - 7 files changed, 122 insertions(+), 163 deletions(-) diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 9f237dd02424..4de6e9280401 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -1,7 +1,7 @@ import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import mimeDb from 'mime-db'; import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; import type {FileObject} from '@components/AttachmentModal'; @@ -9,6 +9,7 @@ import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useKeyboardState from '@hooks/useKeyboardState'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useResetComposerFocus from '@hooks/useResetComposerFocus'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -38,6 +39,7 @@ function Composer( selection, value, isGroupPolicyReport = false, + showSoftInputOnFocus = true, ...props }: ComposerProps, ref: ForwardedRef, @@ -50,7 +52,11 @@ function Composer( const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const [contextMenuHidden, setContextMenuHidden] = useState(true); + const {inputCallbackRef, inputRef: autoFocusInputRef} = useAutoFocusInput(); + const keyboardState = useKeyboardState(); + const isKeyboardShown = keyboardState?.isKeyboardShown ?? false; useEffect(() => { if (autoFocus === !!autoFocusInputRef.current) { @@ -59,6 +65,13 @@ function Composer( inputCallbackRef(autoFocus ? textInput.current : null); }, [autoFocus, inputCallbackRef, autoFocusInputRef]); + useEffect(() => { + if (!showSoftInputOnFocus || !isKeyboardShown) { + return; + } + setContextMenuHidden(false); + }, [showSoftInputOnFocus, isKeyboardShown]); + /** * Set the TextInput Ref * @param {Element} el @@ -137,6 +150,8 @@ function Composer( props?.onBlur?.(e); }} onClear={onClear} + showSoftInputOnFocus={showSoftInputOnFocus} + contextMenuHidden={contextMenuHidden} /> ); } diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 4431007793cb..838a2f6a869d 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -50,6 +50,7 @@ function Composer( isComposerFullSize = false, shouldContainScroll = true, isGroupPolicyReport = false, + showSoftInputOnFocus = true, ...props }: ComposerProps, ref: ForwardedRef, @@ -280,28 +281,24 @@ function Composer( onClear(currentText); }, [onClear, onSelectionChange]); - useImperativeHandle( - ref, - () => { - const textInputRef = textInput.current; - if (!textInputRef) { - throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); - } + useImperativeHandle(ref, () => { + const textInputRef = textInput.current; + if (!textInputRef) { + throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); + } - return { - ...textInputRef, - // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works - clear, - // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly - blur: () => textInputRef.blur(), - focus: () => textInputRef.focus(), - get scrollTop() { - return textInputRef.scrollTop; - }, - }; - }, - [clear], - ); + return { + ...textInputRef, + // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works + clear, + // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly + blur: () => textInputRef.blur(), + focus: () => textInputRef.focus(), + get scrollTop() { + return textInputRef.scrollTop; + }, + }; + }, [clear]); const handleKeyPress = useCallback( (e: NativeSyntheticEvent) => { @@ -349,6 +346,7 @@ function Composer( value={value} defaultValue={defaultValue} autoFocus={autoFocus} + inputMode={showSoftInputOnFocus ? 'text' : 'none'} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} onSelectionChange={addCursorPositionToSelectionChange} diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index ef497dd52e47..7f54c7486e8d 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -74,6 +74,9 @@ type ComposerProps = Omit & { /** Indicates whether the composer is in a group policy report. Used for disabling report mentioning style in markdown input */ isGroupPolicyReport?: boolean; + + /** Whether the soft keyboard is open */ + showSoftInputOnFocus?: boolean; }; export type {TextSelection, ComposerProps, CustomSelectionChangeEvent}; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 4a87d51e3c82..ed4a47f4ff27 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -238,7 +238,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const {reportPendingAction, reportErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report); const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; - const isEmptyChat = useMemo(() => ReportUtils.isEmptyReport(report), [report]); const isOptimisticDelete = report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; const indexOfLinkedMessage = useMemo( (): number => reportActions.findIndex((obj) => String(obj.reportActionID) === String(reportActionIDFromRoute)), @@ -811,7 +810,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro policy={policy} pendingAction={reportPendingAction} isComposerFullSize={!!isComposerFullSize} - isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} workspaceTooltip={workspaceTooltip} /> diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index e63bd952b4ab..12b145a78e87 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -1,6 +1,6 @@ import {useIsFocused, useNavigation} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; -import type {ForwardedRef, MutableRefObject, RefAttributes, RefObject} from 'react'; +import type {ForwardedRef, MutableRefObject, RefObject} from 'react'; import React, {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type { LayoutChangeEvent, @@ -14,7 +14,7 @@ import type { import {DeviceEventEmitter, findNodeHandle, InteractionManager, NativeModules, View} from 'react-native'; import {useFocusedInputHandler} from 'react-native-keyboard-controller'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import {useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; @@ -29,7 +29,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; -import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {forceClearInput} from '@libs/ComponentUtils'; import * as ComposerUtils from '@libs/ComposerUtils'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; @@ -40,7 +39,6 @@ import getPlatform from '@libs/getPlatform'; import * as KeyDownListener from '@libs/KeyboardShortcut/KeyDownPressListener'; import Parser from '@libs/Parser'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; @@ -65,113 +63,85 @@ type SyncSelection = { type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; -type ComposerWithSuggestionsOnyxProps = { - /** The parent report actions for the report */ - parentReportActions: OnyxEntry; +type ComposerWithSuggestionsProps = Partial & { + /** Report ID */ + reportID: string; - /** The modal state */ - modal: OnyxEntry; + /** Callback to focus composer */ + onFocus: () => void; - /** The preferred skin tone of the user */ - preferredSkinTone: number; + /** Callback to blur composer */ + onBlur: (event: NativeSyntheticEvent) => void; - /** Whether the input is focused */ - editFocused: OnyxEntry; -}; - -type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps & - Partial & { - /** Report ID */ - reportID: string; - - /** Callback to focus composer */ - onFocus: () => void; - - /** Callback to blur composer */ - onBlur: (event: NativeSyntheticEvent) => void; - - /** Callback when layout of composer changes */ - onLayout?: (event: LayoutChangeEvent) => void; - - /** Callback to update the value of the composer */ - onValueChange: (value: string) => void; + /** Callback when layout of composer changes */ + onLayout?: (event: LayoutChangeEvent) => void; - /** Callback when the composer got cleared on the UI thread */ - onCleared?: (text: string) => void; + /** Callback to update the value of the composer */ + onValueChange: (value: string) => void; - /** Whether the composer is full size */ - isComposerFullSize: boolean; + /** Callback when the composer got cleared on the UI thread */ + onCleared?: (text: string) => void; - /** Whether the menu is visible */ - isMenuVisible: boolean; + /** Whether the composer is full size */ + isComposerFullSize: boolean; - /** The placeholder for the input */ - inputPlaceholder: string; + /** Whether the menu is visible */ + isMenuVisible: boolean; - /** Function to display a file in a modal */ - displayFileInModal: (file: FileObject) => void; + /** The placeholder for the input */ + inputPlaceholder: string; - /** Whether the user is blocked from concierge */ - isBlockedFromConcierge: boolean; + /** Function to display a file in a modal */ + displayFileInModal: (file: FileObject) => void; - /** Whether the input is disabled */ - disabled: boolean; + /** Whether the user is blocked from concierge */ + isBlockedFromConcierge: boolean; - /** Whether the full composer is available */ - isFullComposerAvailable: boolean; + /** Whether the input is disabled */ + disabled: boolean; - /** Function to set whether the full composer is available */ - setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; + /** Whether the full composer is available */ + isFullComposerAvailable: boolean; - /** Function to set whether the comment is empty */ - setIsCommentEmpty: (isCommentEmpty: boolean) => void; + /** Function to set whether the full composer is available */ + setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; - /** Function to handle sending a message */ - handleSendMessage: () => void; + /** Function to set whether the comment is empty */ + setIsCommentEmpty: (isCommentEmpty: boolean) => void; - /** Whether the compose input should show */ - shouldShowComposeInput: OnyxEntry; + /** Function to handle sending a message */ + handleSendMessage: () => void; - /** Function to measure the parent container */ - measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + /** Whether the compose input should show */ + shouldShowComposeInput: OnyxEntry; - /** Whether the scroll is likely to trigger a layout */ - isScrollLikelyLayoutTriggered: RefObject; + /** Function to measure the parent container */ + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; - /** Function to raise the scroll is likely layout triggered */ - raiseIsScrollLikelyLayoutTriggered: () => void; + /** Whether the scroll is likely to trigger a layout */ + isScrollLikelyLayoutTriggered: RefObject; - /** The ref to the suggestions */ - suggestionsRef: React.RefObject; + /** Function to raise the scroll is likely layout triggered */ + raiseIsScrollLikelyLayoutTriggered: () => void; - /** The ref to the next modal will open */ - isNextModalWillOpenRef: MutableRefObject; + /** The ref to the suggestions */ + suggestionsRef: React.RefObject; - /** Whether the edit is focused */ - editFocused: boolean; + /** The ref to the next modal will open */ + isNextModalWillOpenRef: MutableRefObject; - /** Wheater chat is empty */ - isEmptyChat?: boolean; + /** The last report action */ + lastReportAction?: OnyxEntry; - /** The last report action */ - lastReportAction?: OnyxEntry; + /** Whether to include chronos */ + includeChronos?: boolean; - /** Whether to include chronos */ - includeChronos?: boolean; + /** Whether report is from group policy */ + isGroupPolicyReport: boolean; - /** The parent report action ID */ - parentReportActionID?: string; - - /** The parent report ID */ - // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC - parentReportID: string | undefined; - - /** Whether report is from group policy */ - isGroupPolicyReport: boolean; - - /** policy ID of the report */ - policyID: string; - }; + /** policy ID of the report */ + policyID: string; +}; type SwitchToCurrentReportProps = { preexistingReportID: string; @@ -211,10 +181,6 @@ const debouncedBroadcastUserIsTyping = lodashDebounce( const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); -// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will -// prevent auto focus on existing chat for mobile device -const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); - /** * This component holds the value and selection state. * If a component really needs access to these state values it should be put here. @@ -223,17 +189,10 @@ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); */ function ComposerWithSuggestions( { - // Onyx - modal, - preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, - parentReportActions, - // Props: Report reportID, includeChronos, - isEmptyChat, lastReportAction, - parentReportActionID, isGroupPolicyReport, policyID, @@ -263,7 +222,6 @@ function ComposerWithSuggestions( // Refs suggestionsRef, isNextModalWillOpenRef, - editFocused, // For testing children, @@ -288,6 +246,15 @@ function ComposerWithSuggestions( } return draftComment; }); + + const [modal] = useOnyx(ONYXKEYS.MODAL); + const [preferredSkinTone] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, { + selector: EmojiUtils.getPreferredSkinToneIndex, + initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE, + }); + + const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED); + const commentRef = useRef(value); const lastTextRef = useRef(value); @@ -298,13 +265,7 @@ function ComposerWithSuggestions( const {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const parentReportAction = parentReportActions?.[parentReportActionID ?? '-1']; - const shouldAutoFocus = - !modal?.isVisible && - Modal.areAllModalsHidden() && - isFocused && - (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && - shouldShowComposeInput; + const shouldAutoFocus = !modal?.isVisible && shouldShowComposeInput && Modal.areAllModalsHidden() && isFocused; const valueRef = useRef(value); valueRef.current = value; @@ -313,6 +274,8 @@ function ComposerWithSuggestions( const [composerHeight, setComposerHeight] = useState(0); + const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false); + const textInputRef = useRef(null); const syncSelectionWithOnChangeTextRef = useRef(null); @@ -800,6 +763,19 @@ function ComposerWithSuggestions( onScroll={hideSuggestionMenu} shouldContainScroll={Browser.isMobileSafari()} isGroupPolicyReport={isGroupPolicyReport} + showSoftInputOnFocus={showSoftInputOnFocus} + onTouchStart={() => { + if (showSoftInputOnFocus) { + return; + } + if (Browser.isMobileSafari()) { + setTimeout(() => { + setShowSoftInputOnFocus(true); + }, CONST.ANIMATED_TRANSITION); + return; + } + setShowSoftInputOnFocus(true); + }} /> @@ -837,22 +813,6 @@ ComposerWithSuggestions.displayName = 'ComposerWithSuggestions'; const ComposerWithSuggestionsWithRef = forwardRef(ComposerWithSuggestions); -export default withOnyx, ComposerWithSuggestionsOnyxProps>({ - modal: { - key: ONYXKEYS.MODAL, - }, - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - selector: EmojiUtils.getPreferredSkinToneIndex, - }, - editFocused: { - key: ONYXKEYS.INPUT_FOCUSED, - }, - parentReportActions: { - key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, - canEvict: false, - initWithStoredValues: false, - }, -})(memo(ComposerWithSuggestionsWithRef)); +export default memo(ComposerWithSuggestionsWithRef); export type {ComposerWithSuggestionsProps, ComposerRef}; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 9d34fe86c092..f469d91dbf2d 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -28,7 +28,6 @@ import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import {getDraftComment} from '@libs/DraftCommentUtils'; @@ -64,7 +63,7 @@ type SuggestionsRef = { getIsSuggestionsMenuVisible: () => boolean; }; -type ReportActionComposeProps = Pick & { +type ReportActionComposeProps = Pick & { /** A method to call when the form is submitted */ onSubmit: (newComment: string) => void; @@ -90,10 +89,6 @@ type ReportActionComposeProps = Pick { const initialModalState = getModalState(); - return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; + return shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; }); const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); const [shouldHideEducationalTooltip, setShouldHideEducationalTooltip] = useState(false); @@ -468,11 +462,8 @@ function ReportActionCompose({ raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered} reportID={reportID} policyID={report?.policyID ?? '-1'} - parentReportID={report?.parentReportID} - parentReportActionID={report?.parentReportActionID} includeChronos={ReportUtils.chatIncludesChronos(report)} isGroupPolicyReport={isGroupPolicyReport} - isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} isMenuVisible={isMenuVisible} inputPlaceholder={inputPlaceholder} diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 7c4ec786b633..90746efa3b68 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -48,9 +48,6 @@ type ReportFooterProps = { /** Whether to show educational tooltip in workspace chat for first-time user */ workspaceTooltip: OnyxEntry; - /** Whether the chat is empty */ - isEmptyChat?: boolean; - /** The pending action when we are adding a chat */ pendingAction?: PendingAction; @@ -73,7 +70,6 @@ function ReportFooter({ report = {reportID: '-1'}, reportMetadata, policy, - isEmptyChat = true, isReportReadyForDisplay = true, isComposerFullSize = false, workspaceTooltip, @@ -224,7 +220,6 @@ function ReportFooter({ onComposerBlur={onComposerBlur} reportID={report.reportID} report={report} - isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} pendingAction={pendingAction} isComposerFullSize={isComposerFullSize} @@ -246,7 +241,6 @@ export default memo( lodashIsEqual(prevProps.report, nextProps.report) && prevProps.pendingAction === nextProps.pendingAction && prevProps.isComposerFullSize === nextProps.isComposerFullSize && - prevProps.isEmptyChat === nextProps.isEmptyChat && prevProps.lastReportAction === nextProps.lastReportAction && prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay && prevProps.workspaceTooltip?.shouldShow === nextProps.workspaceTooltip?.shouldShow && From 1af3ada68d4a1f75c3798773b93b74fbbb7af1e0 Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 21 Oct 2024 17:54:13 +0700 Subject: [PATCH 040/593] refactor --- .../Composer/implementation/index.tsx | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 838a2f6a869d..e40bb716e0a0 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -281,24 +281,28 @@ function Composer( onClear(currentText); }, [onClear, onSelectionChange]); - useImperativeHandle(ref, () => { - const textInputRef = textInput.current; - if (!textInputRef) { - throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); - } + useImperativeHandle( + ref, + () => { + const textInputRef = textInput.current; + if (!textInputRef) { + throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); + } - return { - ...textInputRef, - // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works - clear, - // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly - blur: () => textInputRef.blur(), - focus: () => textInputRef.focus(), - get scrollTop() { - return textInputRef.scrollTop; - }, - }; - }, [clear]); + return { + ...textInputRef, + // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works + clear, + // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly + blur: () => textInputRef.blur(), + focus: () => textInputRef.focus(), + get scrollTop() { + return textInputRef.scrollTop; + }, + }; + }, + [clear], + ); const handleKeyPress = useCallback( (e: NativeSyntheticEvent) => { From c4cb4cb08c0edc598aba0337864473a3b812471f Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Mon, 28 Oct 2024 13:46:08 +0100 Subject: [PATCH 041/593] fix: ci checks --- .../ActionSheetKeyboardSpace.tsx | 2 +- src/components/EmojiPicker/EmojiPickerButton.tsx | 6 +++--- .../MoneyRequestConfirmationListFooter.tsx | 1 + src/components/SelectionList/ChatListItem.tsx | 1 + src/pages/TransactionDuplicate/Confirmation.tsx | 1 + src/pages/home/report/ReportActionItem.tsx | 13 ++++++++++++- 6 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index afb4f674438e..55ad0877f31b 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -88,7 +88,7 @@ function ActionSheetKeyboardSpace(props: ViewProps) { useAnimatedReaction( () => keyboard.state.value, (lastState) => { - if (lastState === syncLocalWorkletStateL.lastState) { + if (lastState === syncLocalWorkletStateL.value) { return; } // eslint-disable-next-line react-compiler/react-compiler diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index 6182d10c88bd..9b64467c9716 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -1,8 +1,8 @@ import {useIsFocused} from '@react-navigation/native'; import React, {memo, useEffect, useRef} from 'react'; -import type {GestureResponderEvent} from 'react-native'; 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 +20,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; @@ -37,7 +37,7 @@ function EmojiPickerButton({isDisabled = false, emojiPickerID = '', shiftVertica const {translate} = useLocalize(); const isFocused = useIsFocused(); - const openEmojiPicker = (e: GestureResponderEvent | KeyboardEvent) => { + const openEmojiPicker: PressableProps['onPress'] = (e) => { if (!isFocused) { return; } diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index dcfe72369651..9f8dc76cf1ca 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -285,6 +285,7 @@ function MoneyRequestConfirmationListFooter({ reportNameValuePairs: undefined, action: undefined, checkIfContextMenuActive: () => {}, + onShowContextMenu: () => {}, isDisabled: true, }), [], diff --git a/src/components/SelectionList/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx index 52b42b0c64dd..03071a45be8f 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/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx index 87748a9697a7..5a67fc03fff2 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/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index f9acddb550cf..1abb35916889 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -391,7 +391,18 @@ function ReportActionItem({ ); }); }, - [draftMessage, action, reportID, toggleContextMenuFromActiveReportAction, originalReportID, shouldDisplayContextMenu, disabledActions, isArchivedRoom, isChronosReport], + [ + draftMessage, + action, + reportID, + toggleContextMenuFromActiveReportAction, + originalReportID, + shouldDisplayContextMenu, + disabledActions, + isArchivedRoom, + isChronosReport, + handleShowContextMenu, + ], ); // Handles manual scrolling to the bottom of the chat when the last message is an actionable whisper and it's resolved. From 9d271ae485c938de5e3745eef202ba4270ed576d Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Mon, 28 Oct 2024 14:02:19 +0100 Subject: [PATCH 042/593] fix: don't mock keyboard-controller twice --- jest/setup.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index 093435971929..7dbe91c32fda 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -64,9 +64,6 @@ jest.mock('react-native-sound', () => { return SoundMock; }); -// eslint-disable-next-line @typescript-eslint/no-unsafe-return -jest.mock('react-native-keyboard-controller', () => require('react-native-keyboard-controller/jest')); - jest.mock('react-native-share', () => ({ default: jest.fn(), })); From 31ba00b22e78526e6e8d54866e9ed619f98b27fe Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Mon, 28 Oct 2024 15:43:47 +0100 Subject: [PATCH 043/593] fix: button jumps in Display Name page --- src/components/ScreenWrapper.tsx | 7 +++++ src/hooks/useSafePaddingBottomStyle.ts | 30 +++++++------------ .../settings/Profile/DisplayNamePage.tsx | 1 + 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 3645b832ed43..203217b0b1f4 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -11,6 +11,7 @@ import useInitialDimensions from '@hooks/useInitialWindowDimensions'; import useKeyboardState from '@hooks/useKeyboardState'; import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {useSafePaddingBottomValue} from '@hooks/useSafePaddingBottomStyle'; import useTackInputFocus from '@hooks/useTackInputFocus'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -69,6 +70,9 @@ type ScreenWrapperProps = { /** Whether KeyboardAvoidingView should be enabled. Use false for screens where this functionality is not necessary */ shouldEnableKeyboardAvoidingView?: boolean; + /** Whether to remove the safe padding bottom (produced by `useSafePaddingBottomStyle`) when the keyboard is shown */ + shouldRemoveSafePaddingBottomWhenKeyboardShown?: boolean; + /** Whether picker modal avoiding should be enabled. Should be enabled when there's a picker at the bottom of a * scrollable form, gives a subtly better UX if disabled on non-scrollable screens with a submit button */ shouldEnablePickerAvoiding?: boolean; @@ -119,6 +123,7 @@ function ScreenWrapper( includeSafeAreaPaddingBottom = true, shouldEnableKeyboardAvoidingView = true, shouldEnablePickerAvoiding = true, + shouldRemoveSafePaddingBottomWhenKeyboardShown = false, headerGapStyles, children, shouldShowOfflineIndicator = true, @@ -152,6 +157,7 @@ function ScreenWrapper( const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const {initialHeight} = useInitialDimensions(); const styles = useThemeStyles(); + const safePaddingBottom = useSafePaddingBottomValue(); const keyboardState = useKeyboardState(); const {isDevelopment} = useEnvironment(); const {isOffline} = useNetwork(); @@ -271,6 +277,7 @@ function ScreenWrapper( style={[styles.w100, styles.h100, {maxHeight}, isAvoidingViewportScroll ? [styles.overflowAuto, styles.overscrollBehaviorContain] : {}]} behavior={keyboardAvoidingViewBehavior} enabled={shouldEnableKeyboardAvoidingView} + keyboardVerticalOffset={shouldRemoveSafePaddingBottomWhenKeyboardShown ? -safePaddingBottom : undefined} > { +const useSafePaddingBottomStyle = (): ViewStyle => { const styles = useThemeStyles(); - const [willKeyboardShow, setWillKeyboardShow] = useState(false); - useEffect(() => { - const keyboardWillShowListener = Keyboard.addListener('keyboardWillShow', () => { - setWillKeyboardShow(true); - }); - const keyboardWillHideListener = Keyboard.addListener('keyboardWillHide', () => { - setWillKeyboardShow(false); - }); - return () => { - keyboardWillShowListener.remove(); - keyboardWillHideListener.remove(); - }; - }, []); - const {paddingBottom} = useStyledSafeAreaInsets(); const extraPaddingBottomStyle = useMemo(() => { // Do not add extra padding at the bottom if the keyboard is open or if there is no safe area bottom padding style. - if (willKeyboardShow || !paddingBottom) { + if (!paddingBottom) { return {}; } return styles.pb5; - }, [willKeyboardShow, paddingBottom, styles.pb5]); + }, [paddingBottom, styles.pb5]); return extraPaddingBottomStyle; }; +const useSafePaddingBottomValue = () => { + const style = useSafePaddingBottomStyle(); + + return style.paddingBottom ?? 0; +}; +export {useSafePaddingBottomValue}; export default useSafePaddingBottomStyle; diff --git a/src/pages/settings/Profile/DisplayNamePage.tsx b/src/pages/settings/Profile/DisplayNamePage.tsx index 90f7ca3abbd6..c26aa3f92793 100644 --- a/src/pages/settings/Profile/DisplayNamePage.tsx +++ b/src/pages/settings/Profile/DisplayNamePage.tsx @@ -71,6 +71,7 @@ function DisplayNamePage({isLoadingApp = true, currentUserPersonalDetails}: Disp return ( From 491c481a0c1d9049615d1f93c9cdb92c6238c3da Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Mon, 28 Oct 2024 16:16:55 +0100 Subject: [PATCH 044/593] fix: safe area glitch on workspace selection screen --- src/components/SelectionList/BaseSelectionList.tsx | 3 ++- src/components/SelectionList/types.ts | 3 +++ src/pages/WorkspaceSwitcherPage/index.tsx | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 57423992e43e..ae9242d47084 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -106,6 +106,7 @@ function BaseSelectionList( scrollEventThrottle, contentContainerStyle, shouldHighlightSelectedItem = false, + shouldHandleSafeAreaPaddings = true, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -671,7 +672,7 @@ function BaseSelectionList( return ( {({safeAreaPaddingBottomStyle}) => ( - + {shouldShowTextInput && ( = Partial & { /** Whether we highlight all the selected items */ shouldHighlightSelectedItem?: boolean; + + /** Whether safe area paddings should be handled */ + shouldHandleSafeAreaPaddings?: boolean; } & TRightHandSideComponent; type SelectionListHandle = { diff --git a/src/pages/WorkspaceSwitcherPage/index.tsx b/src/pages/WorkspaceSwitcherPage/index.tsx index 221889b80b49..f3e67f73d713 100644 --- a/src/pages/WorkspaceSwitcherPage/index.tsx +++ b/src/pages/WorkspaceSwitcherPage/index.tsx @@ -159,7 +159,7 @@ function WorkspaceSwitcherPage() { return ( {({didScreenTransitionEnd}) => ( <> @@ -196,6 +196,7 @@ function WorkspaceSwitcherPage() { shouldShowListEmptyContent={shouldShowCreateWorkspace} initiallyFocusedOptionKey={activeWorkspaceID ?? CONST.WORKSPACE_SWITCHER.NAME} showLoadingPlaceholder={fetchStatus.status === 'loading' || !didScreenTransitionEnd} + shouldHandleSafeAreaPaddings={false} /> )} From ec57d6f4aae6e825263a6f6d6f83a010ef4896cc Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Mon, 28 Oct 2024 18:09:48 +0100 Subject: [PATCH 045/593] fix: emoji picker transition --- src/components/EmojiPicker/EmojiPickerButton.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/EmojiPicker/EmojiPickerButton.tsx b/src/components/EmojiPicker/EmojiPickerButton.tsx index 9b64467c9716..a10e7d9fd1f3 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.tsx +++ b/src/components/EmojiPicker/EmojiPickerButton.tsx @@ -1,5 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {memo, useEffect, useRef} from 'react'; +import React, {memo, useContext, useEffect, useRef} from 'react'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import type PressableProps from '@components/Pressable/GenericPressable/types'; @@ -31,6 +32,7 @@ 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); @@ -42,6 +44,10 @@ function EmojiPickerButton({isDisabled = false, emojiPickerID = '', shiftVertica return; } + actionSheetContext.transitionActionSheetState({ + type: ActionSheetAwareScrollView.Actions.CLOSE_KEYBOARD, + }); + if (!EmojiPickerAction.emojiPickerRef?.current?.isEmojiPickerVisible) { EmojiPickerAction.showEmojiPicker( onModalHide, From b6073e408b9774078315e7ad07664035de039617 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 29 Oct 2024 14:24:01 +0100 Subject: [PATCH 046/593] fix: random jump when keyboard closes --- .../ActionSheetKeyboardSpace.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx index 55ad0877f31b..47dd173e8e8d 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetKeyboardSpace.tsx @@ -1,7 +1,7 @@ import React, {useContext, useEffect} from 'react'; import type {ViewProps} from 'react-native'; import {useKeyboardHandler} from 'react-native-keyboard-controller'; -import Reanimated, {runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, withTiming} from 'react-native-reanimated'; +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'; @@ -75,9 +75,9 @@ function ActionSheetKeyboardSpace(props: ViewProps) { const keyboard = useAnimatedKeyboard(); // similar to using `global` in worklet but it's just a local object - const syncLocalWorkletStateL = useSharedValue(KeyboardState.UNKNOWN); + const syncLocalWorkletState = useSharedValue(KeyboardState.UNKNOWN); const {windowHeight} = useWindowDimensions(); - const {currentActionSheetState, transitionActionSheetStateWorklet: transition, transitionActionSheetState, resetStateMachine} = useContext(ActionSheetAwareScrollViewContext); + const {currentActionSheetState, transitionActionSheetStateWorklet: transition, resetStateMachine} = useContext(ActionSheetAwareScrollViewContext); // Reset state machine when component unmounts // eslint-disable-next-line arrow-body-style @@ -88,16 +88,16 @@ function ActionSheetKeyboardSpace(props: ViewProps) { useAnimatedReaction( () => keyboard.state.value, (lastState) => { - if (lastState === syncLocalWorkletStateL.value) { + if (lastState === syncLocalWorkletState.value) { return; } // eslint-disable-next-line react-compiler/react-compiler - syncLocalWorkletStateL.value = lastState; + syncLocalWorkletState.value = lastState; if (lastState === KeyboardState.OPEN) { - runOnJS(transitionActionSheetState)({type: Actions.OPEN_KEYBOARD}); + transition({type: Actions.OPEN_KEYBOARD}); } else if (lastState === KeyboardState.CLOSED) { - runOnJS(transitionActionSheetState)({type: Actions.CLOSE_KEYBOARD}); + transition({type: Actions.CLOSE_KEYBOARD}); } }, [], @@ -125,9 +125,9 @@ function ActionSheetKeyboardSpace(props: ViewProps) { ? previousPayload.fy + safeArea.top + previousPayload.height - (windowHeight - previousPayload.popoverHeight) : 0; - const isOpeningKeyboard = syncLocalWorkletStateL.value === 1; - const isClosingKeyboard = syncLocalWorkletStateL.value === 3; - const isClosedKeyboard = syncLocalWorkletStateL.value === 4; + const isOpeningKeyboard = syncLocalWorkletState.value === 1; + const isClosingKeyboard = syncLocalWorkletState.value === 3; + const isClosedKeyboard = syncLocalWorkletState.value === 4; // Depending on the current and sometimes previous state we can return // either animation or just a value switch (current.state) { From d186f7f68418606610c2c0c6058e5dfb720bada0 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 29 Oct 2024 15:11:46 +0100 Subject: [PATCH 047/593] fix: eslint --- src/pages/WorkspaceSwitcherPage/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/WorkspaceSwitcherPage/index.tsx b/src/pages/WorkspaceSwitcherPage/index.tsx index f3e67f73d713..7775f1f0a635 100644 --- a/src/pages/WorkspaceSwitcherPage/index.tsx +++ b/src/pages/WorkspaceSwitcherPage/index.tsx @@ -159,7 +159,7 @@ function WorkspaceSwitcherPage() { return ( {({didScreenTransitionEnd}) => ( <> From 62513eef4806ad194c134b38644a41206c256afc Mon Sep 17 00:00:00 2001 From: christianwen Date: Thu, 31 Oct 2024 14:48:36 +0700 Subject: [PATCH 048/593] fix bugs flicker --- src/pages/home/ReportScreen.tsx | 5 ++++- .../ComposerWithSuggestions.tsx | 10 ++++++++-- .../ReportActionCompose/ReportActionCompose.tsx | 10 ++++++++++ src/pages/home/report/ReportFooter.tsx | 12 ++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 1027f4c05d3c..5de2652632a8 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -266,6 +266,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`]; const isTopMostReportId = currentReportID === reportIDFromRoute; const didSubscribeToReportLeavingEvents = useRef(false); + const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false); useEffect(() => { if (!report?.reportID || shouldHideReport) { @@ -711,7 +712,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro ) : null} diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 12b145a78e87..41fd13aff99b 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -141,6 +141,12 @@ type ComposerWithSuggestionsProps = Partial & { /** policy ID of the report */ policyID: string; + + /** Whether the soft keyboard is open */ + showSoftInputOnFocus: boolean; + + /** A method to update showSoftInputOnFocus */ + setShowSoftInputOnFocus: (value: boolean) => void; }; type SwitchToCurrentReportProps = { @@ -225,6 +231,8 @@ function ComposerWithSuggestions( // For testing children, + showSoftInputOnFocus, + setShowSoftInputOnFocus, }: ComposerWithSuggestionsProps, ref: ForwardedRef, ) { @@ -274,8 +282,6 @@ function ComposerWithSuggestions( const [composerHeight, setComposerHeight] = useState(0); - const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false); - const textInputRef = useRef(null); const syncSelectionWithOnChangeTextRef = useRef(null); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index aee324cd745f..16973a5cea41 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -87,6 +87,12 @@ type ReportActionComposeProps = Pick void; }; const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); @@ -104,8 +110,10 @@ function ReportActionCompose({ isReportReadyForDisplay = true, lastReportAction, shouldShowEducationalTooltip, + showSoftInputOnFocus, onComposerFocus, onComposerBlur, + setShowSoftInputOnFocus, }: ReportActionComposeProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -487,6 +495,8 @@ function ReportActionCompose({ } validateCommentMaxLength(value, {reportID}); }} + showSoftInputOnFocus={showSoftInputOnFocus} + setShowSoftInputOnFocus={setShowSoftInputOnFocus} /> { diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index ac2456a5b4d5..1e782e4acdee 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -62,6 +62,12 @@ type ReportFooterProps = { /** A method to call when the input is blur */ onComposerBlur: () => void; + + /** Whether the soft keyboard is open */ + showSoftInputOnFocus: boolean; + + /** A method to update showSoftInputOnFocus */ + setShowSoftInputOnFocus: (value: boolean) => void; }; function ReportFooter({ @@ -73,8 +79,10 @@ function ReportFooter({ isReportReadyForDisplay = true, isComposerFullSize = false, workspaceTooltip, + showSoftInputOnFocus, onComposerBlur, onComposerFocus, + setShowSoftInputOnFocus, }: ReportFooterProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -179,6 +187,7 @@ function ReportFooter({ [report.reportID, handleCreateTask], ); + console.log('9999', showSoftInputOnFocus); return ( <> {!!shouldHideComposer && ( @@ -225,6 +234,8 @@ function ReportFooter({ isComposerFullSize={isComposerFullSize} isReportReadyForDisplay={isReportReadyForDisplay} shouldShowEducationalTooltip={didScreenTransitionEnd && shouldShowEducationalTooltip} + showSoftInputOnFocus={showSoftInputOnFocus} + setShowSoftInputOnFocus={setShowSoftInputOnFocus} /> @@ -244,6 +255,7 @@ export default memo( prevProps.lastReportAction === nextProps.lastReportAction && prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay && prevProps.workspaceTooltip?.shouldShow === nextProps.workspaceTooltip?.shouldShow && + prevProps.showSoftInputOnFocus === nextProps.showSoftInputOnFocus && lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) && lodashIsEqual(prevProps.policy?.employeeList, nextProps.policy?.employeeList) && lodashIsEqual(prevProps.policy?.role, nextProps.policy?.role), From e4823f07f53dc85f09453d56f687fb5c54825c1f Mon Sep 17 00:00:00 2001 From: christianwen Date: Thu, 31 Oct 2024 14:52:01 +0700 Subject: [PATCH 049/593] remove console --- src/pages/home/report/ReportFooter.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 1e782e4acdee..1e5a2f4c0283 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -187,7 +187,6 @@ function ReportFooter({ [report.reportID, handleCreateTask], ); - console.log('9999', showSoftInputOnFocus); return ( <> {!!shouldHideComposer && ( From f8c18261fea2fb263d8638005fb8dba4e6bd3c34 Mon Sep 17 00:00:00 2001 From: christianwen Date: Thu, 31 Oct 2024 15:02:07 +0700 Subject: [PATCH 050/593] fix ts --- tests/perf-test/ReportActionCompose.perf-test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx index 845727c75c97..1827e23ffe4b 100644 --- a/tests/perf-test/ReportActionCompose.perf-test.tsx +++ b/tests/perf-test/ReportActionCompose.perf-test.tsx @@ -96,6 +96,8 @@ function ReportActionComposeWrapper() { disabled={false} report={LHNTestUtils.getFakeReport()} isComposerFullSize + showSoftInputOnFocus={false} + setShowSoftInputOnFocus={() => {}} /> ); From 3ef967ffbeaf08b124ad7a8d9aebb8a41aabad4d Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 31 Oct 2024 14:21:22 +0100 Subject: [PATCH 051/593] fix: bottom sheet avoidance when keyboard is hidden --- src/components/PopoverWithMeasuredContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx index 725f07eb24a7..f032fbd1bbbf 100644 --- a/src/components/PopoverWithMeasuredContent.tsx +++ b/src/components/PopoverWithMeasuredContent.tsx @@ -98,7 +98,7 @@ function PopoverWithMeasuredContent({ // it handles the case when `measurePopover` is called with values like: 192, 192.00003051757812, 192 // if we update it, then animation in `ActionSheetAwareScrollView` may be re-running // and we'll see unsynchronized and junky animation - if (actionSheetAwareScrollViewContext.currentActionSheetState.value.current.payload?.popoverHeight !== Math.floor(popoverHeight)) { + if (actionSheetAwareScrollViewContext.currentActionSheetState.value.current.payload?.popoverHeight !== Math.floor(height) && height !== 0) { actionSheetAwareScrollViewContext.transitionActionSheetState({ type: ActionSheetAwareScrollView.Actions.MEASURE_POPOVER, payload: { From e6de40d7845f2cddcd30f7852aff63982b69687d Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 31 Oct 2024 17:12:15 +0100 Subject: [PATCH 052/593] fix: random transitions when popover gets closed --- .../ActionSheetAwareScrollViewContext.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx index 4f56185d64da..00b662aaba8c 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -99,6 +99,7 @@ const STATE_MACHINE = { [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.CALL_POPOVER_WITH_KEYBOARD_OPEN]: { [Actions.MEASURE_POPOVER]: States.CALL_POPOVER_WITH_KEYBOARD_OPEN, From e90556ddfc055271e17482b61960285ebce9a5f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 29 Oct 2024 14:43:47 +0100 Subject: [PATCH 053/593] Revert "Revert "Revert "Revert "Search suffix tree implementation"""" This reverts commit 20af9f625b5e636f3e2cce928d4eb5ab5954f8ee. --- src/CONST.ts | 3 + .../Search/SearchRouter/SearchRouter.tsx | 62 ++++- src/libs/FastSearch.ts | 140 ++++++++++++ src/libs/OptionsListUtils.ts | 38 +++- src/libs/SuffixUkkonenTree/index.ts | 211 ++++++++++++++++++ src/libs/SuffixUkkonenTree/utils.ts | 115 ++++++++++ tests/unit/FastSearchTest.ts | 118 ++++++++++ tests/unit/SuffixUkkonenTreeTest.ts | 63 ++++++ 8 files changed, 736 insertions(+), 14 deletions(-) create mode 100644 src/libs/FastSearch.ts create mode 100644 src/libs/SuffixUkkonenTree/index.ts create mode 100644 src/libs/SuffixUkkonenTree/utils.ts create mode 100644 tests/unit/FastSearchTest.ts create mode 100644 tests/unit/SuffixUkkonenTreeTest.ts diff --git a/src/CONST.ts b/src/CONST.ts index 437ee4e7fd42..871f7730a03d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1262,6 +1262,9 @@ const CONST = { SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300, RESIZE_DEBOUNCE_TIME: 100, UNREAD_UPDATE_DEBOUNCE_TIME: 300, + SEARCH_CONVERT_SEARCH_VALUES: 'search_convert_search_values', + SEARCH_MAKE_TREE: 'search_make_tree', + SEARCH_BUILD_TREE: 'search_build_tree', SEARCH_FILTER_OPTIONS: 'search_filter_options', USE_DEBOUNCED_STATE_DELAY: 300, }, diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 83d7d5d89b20..6f5481a17983 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -15,6 +15,7 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import FastSearch from '@libs/FastSearch'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -105,6 +106,49 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return OptionsListUtils.getSearchOptions(options, '', betas ?? []); }, [areOptionsInitialized, betas, options]); + /** + * Builds a suffix tree and returns a function to search in it. + */ + const findInSearchTree = useMemo(() => { + const fastSearch = FastSearch.createFastSearch([ + { + data: searchOptions.personalDetails, + toSearchableString: (option) => { + const displayName = option.participantsList?.[0]?.displayName ?? ''; + return [option.login ?? '', option.login !== displayName ? displayName : ''].join(); + }, + }, + { + data: searchOptions.recentReports, + toSearchableString: (option) => { + const searchStringForTree = [option.text ?? '', option.login ?? '']; + + if (option.isThread) { + if (option.alternateText) { + searchStringForTree.push(option.alternateText); + } + } else if (!!option.isChatRoom || !!option.isPolicyExpenseChat) { + if (option.subtitle) { + searchStringForTree.push(option.subtitle); + } + } + + return searchStringForTree.join(); + }, + }, + ]); + function search(searchInput: string) { + const [personalDetails, recentReports] = fastSearch.search(searchInput); + + return { + personalDetails, + recentReports, + }; + } + + return search; + }, [searchOptions.personalDetails, searchOptions.recentReports]); + const filteredOptions = useMemo(() => { if (debouncedInputValue.trim() === '') { return { @@ -115,15 +159,25 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); + const newOptions = findInSearchTree(debouncedInputValue); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); - return { + const recentReports = newOptions.recentReports.concat(newOptions.personalDetails); + + const userToInvite = OptionsListUtils.pickUserToInvite({ + canInviteUser: true, recentReports: newOptions.recentReports, personalDetails: newOptions.personalDetails, - userToInvite: newOptions.userToInvite, + searchValue: debouncedInputValue, + optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}], + }); + + return { + recentReports, + personalDetails: [], + userToInvite, }; - }, [debouncedInputValue, searchOptions]); + }, [debouncedInputValue, findInSearchTree]); const recentReports: OptionData[] = useMemo(() => { if (debouncedInputValue === '') { diff --git a/src/libs/FastSearch.ts b/src/libs/FastSearch.ts new file mode 100644 index 000000000000..59d28dedd449 --- /dev/null +++ b/src/libs/FastSearch.ts @@ -0,0 +1,140 @@ +/* eslint-disable rulesdir/prefer-at */ +import CONST from '@src/CONST'; +import Timing from './actions/Timing'; +import SuffixUkkonenTree from './SuffixUkkonenTree'; + +type SearchableData = { + /** + * The data that should be searchable + */ + data: T[]; + /** + * A function that generates a string from a data entry. The string's value is used for searching. + * If you have multiple fields that should be searchable, simply concat them to the string and return it. + */ + toSearchableString: (data: T) => string; +}; + +// There are certain characters appear very often in our search data (email addresses), which we don't need to search for. +const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ']); + +/** + * Creates a new "FastSearch" instance. "FastSearch" uses a suffix tree to search for substrings in a list of strings. + * You can provide multiple datasets. The search results will be returned for each dataset. + * + * Note: Creating a FastSearch instance with a lot of data is computationally expensive. You should create an instance once and reuse it. + * Searches will be very fast though, even with a lot of data. + */ +function createFastSearch(dataSets: Array>) { + Timing.start(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES); + const maxNumericListSize = 400_000; + // The user might provide multiple data sets, but internally, the search values will be stored in this one list: + let concatenatedNumericList = new Uint8Array(maxNumericListSize); + // Here we store the index of the data item in the original data list, so we can map the found occurrences back to the original data: + const occurrenceToIndex = new Uint32Array(maxNumericListSize * 4); + // As we are working with ArrayBuffers, we need to keep track of the current offset: + const offset = {value: 1}; + // We store the last offset for a dataSet, so we can map the found occurrences to the correct dataSet: + const listOffsets: number[] = []; + + for (const {data, toSearchableString} of dataSets) { + // Performance critical: the array parameters are passed by reference, so we don't have to create new arrays every time: + dataToNumericRepresentation(concatenatedNumericList, occurrenceToIndex, offset, {data, toSearchableString}); + listOffsets.push(offset.value); + } + concatenatedNumericList[offset.value++] = SuffixUkkonenTree.END_CHAR_CODE; + listOffsets[listOffsets.length - 1] = offset.value; + Timing.end(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES); + + // The list might be larger than necessary, so we clamp it to the actual size: + concatenatedNumericList = concatenatedNumericList.slice(0, offset.value); + + // Create & build the suffix tree: + Timing.start(CONST.TIMING.SEARCH_MAKE_TREE); + const tree = SuffixUkkonenTree.makeTree(concatenatedNumericList); + Timing.end(CONST.TIMING.SEARCH_MAKE_TREE); + + Timing.start(CONST.TIMING.SEARCH_BUILD_TREE); + tree.build(); + Timing.end(CONST.TIMING.SEARCH_BUILD_TREE); + + /** + * Searches for the given input and returns results for each dataset. + */ + function search(searchInput: string): T[][] { + const cleanedSearchString = cleanString(searchInput); + const {numeric} = SuffixUkkonenTree.stringToNumeric(cleanedSearchString, { + charSetToSkip, + // stringToNumeric might return a list that is larger than necessary, so we clamp it to the actual size + // (otherwise the search could fail as we include in our search empty array values): + clamp: true, + }); + const result = tree.findSubstring(Array.from(numeric)); + + const resultsByDataSet = Array.from({length: dataSets.length}, () => new Set()); + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < result.length; i++) { + const occurrenceIndex = result[i]; + const itemIndexInDataSet = occurrenceToIndex[occurrenceIndex]; + const dataSetIndex = listOffsets.findIndex((listOffset) => occurrenceIndex < listOffset); + + if (dataSetIndex === -1) { + throw new Error(`[FastSearch] The occurrence index ${occurrenceIndex} is not in any dataset`); + } + const item = dataSets[dataSetIndex].data[itemIndexInDataSet]; + if (!item) { + throw new Error(`[FastSearch] The item with index ${itemIndexInDataSet} in dataset ${dataSetIndex} is not defined`); + } + resultsByDataSet[dataSetIndex].add(item); + } + + return resultsByDataSet.map((set) => Array.from(set)); + } + + return { + search, + }; +} + +/** + * The suffix tree can only store string like values, and internally stores those as numbers. + * This function converts the user data (which are most likely objects) to a numeric representation. + * Additionally a list of the original data and their index position in the numeric list is created, which is used to map the found occurrences back to the original data. + */ +function dataToNumericRepresentation(concatenatedNumericList: Uint8Array, occurrenceToIndex: Uint32Array, offset: {value: number}, {data, toSearchableString}: SearchableData): void { + data.forEach((option, index) => { + const searchStringForTree = toSearchableString(option); + const cleanedSearchStringForTree = cleanString(searchStringForTree); + + if (cleanedSearchStringForTree.length === 0) { + return; + } + + SuffixUkkonenTree.stringToNumeric(cleanedSearchStringForTree, { + charSetToSkip, + out: { + outArray: concatenatedNumericList, + offset, + outOccurrenceToIndex: occurrenceToIndex, + index, + }, + }); + // eslint-disable-next-line no-param-reassign + occurrenceToIndex[offset.value] = index; + // eslint-disable-next-line no-param-reassign + concatenatedNumericList[offset.value++] = SuffixUkkonenTree.DELIMITER_CHAR_CODE; + }); +} + +/** + * Everything in the tree is treated as lowercase. + */ +function cleanString(input: string) { + return input.toLowerCase(); +} + +const FastSearch = { + createFastSearch, +}; + +export default FastSearch; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 497a2d33cf56..f414d2328ef6 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2477,6 +2477,31 @@ function getPersonalDetailSearchTerms(item: Partial) { function getCurrentUserSearchTerms(item: ReportUtils.OptionData) { return [item.text ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? '']; } + +type PickUserToInviteParams = { + canInviteUser: boolean; + recentReports: ReportUtils.OptionData[]; + personalDetails: ReportUtils.OptionData[]; + searchValue: string; + config?: FilterOptionsConfig; + optionsToExclude: Option[]; +}; + +const pickUserToInvite = ({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}: PickUserToInviteParams) => { + let userToInvite = null; + if (canInviteUser) { + if (recentReports.length === 0 && personalDetails.length === 0) { + userToInvite = getUserToInviteOption({ + searchValue, + selectedOptions: config?.selectedOptions, + optionsToExclude, + }); + } + } + + return userToInvite; +}; + /** * Filters options based on the search input value */ @@ -2564,16 +2589,7 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt recentReports = orderOptions(recentReports, searchValue); } - let userToInvite = null; - if (canInviteUser) { - if (recentReports.length === 0 && personalDetails.length === 0) { - userToInvite = getUserToInviteOption({ - searchValue, - selectedOptions: config?.selectedOptions, - optionsToExclude, - }); - } - } + const userToInvite = pickUserToInvite({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}); if (maxRecentReportsToShow > 0 && recentReports.length > maxRecentReportsToShow) { recentReports.splice(maxRecentReportsToShow); @@ -2643,6 +2659,7 @@ export { formatMemberForList, formatSectionsFromSearchTerm, getShareLogOptions, + orderOptions, filterOptions, createOptionList, createOptionFromReport, @@ -2657,6 +2674,7 @@ export { shouldUseBoldText, getAttendeeOptions, getAlternateText, + pickUserToInvite, hasReportErrors, }; diff --git a/src/libs/SuffixUkkonenTree/index.ts b/src/libs/SuffixUkkonenTree/index.ts new file mode 100644 index 000000000000..bcefd1008493 --- /dev/null +++ b/src/libs/SuffixUkkonenTree/index.ts @@ -0,0 +1,211 @@ +/* eslint-disable rulesdir/prefer-at */ +// .at() has a performance overhead we explicitly want to avoid here + +/* eslint-disable no-continue */ +import {ALPHABET_SIZE, DELIMITER_CHAR_CODE, END_CHAR_CODE, SPECIAL_CHAR_CODE, stringToNumeric} from './utils'; + +/** + * This implements a suffix tree using Ukkonen's algorithm. + * A good visualization to learn about the algorithm can be found here: https://brenden.github.io/ukkonen-animation/ + * A good video explaining Ukkonen's algorithm can be found here: https://www.youtube.com/watch?v=ALEV0Hc5dDk + * Note: This implementation is optimized for performance, not necessarily for readability. + * + * You probably don't want to use this directly, but rather use @libs/FastSearch.ts as a easy to use wrapper around this. + */ + +/** + * Creates a new tree instance that can be used to build a suffix tree and search in it. + * The input is a numeric representation of the search string, which can be created using {@link stringToNumeric}. + * Separate search values must be separated by the {@link DELIMITER_CHAR_CODE}. The search string must end with the {@link END_CHAR_CODE}. + * + * The tree will be built using the Ukkonen's algorithm: https://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf + */ +function makeTree(numericSearchValues: Uint8Array) { + // Every leaf represents a suffix. There can't be more than n suffixes. + // Every internal node has to have at least 2 children. So the total size of ukkonen tree is not bigger than 2n - 1. + // + 1 is because an extra character at the beginning to offset the 1-based indexing. + const maxNodes = 2 * numericSearchValues.length + 1; + /* + This array represents all internal nodes in the suffix tree. + When building this tree, we'll be given a character in the string, and we need to be able to lookup in constant time + if there's any edge connected to a node starting with that character. For example, given a tree like this: + + root + / | \ + a b c + + and the next character in our string is 'd', we need to be able do check if any of the edges from the root node + start with the letter 'd', without looping through all the edges. + + To accomplish this, each node gets an array matching the alphabet size. + So you can imagine if our alphabet was just [a,b,c,d], then each node would get an array like [0,0,0,0]. + If we add an edge starting with 'a', then the root node would be [1,0,0,0] + So given an arbitrary letter such as 'd', then we can take the position of that letter in its alphabet (position 3 in our example) + and check whether that index in the array is 0 or 1. If it's a 1, then there's an edge starting with the letter 'd'. + + Note that for efficiency, all nodes are stored in a single flat array. That's how we end up with (maxNodes * alphabet_size). + In the example of a 4-character alphabet, we'd have an array like this: + + root root.left root.right last possible node + / \ / \ / \ / \ + [0,0,0,0, 0,0,0,0, 0,0,0,0, ................. 0,0,0,0] + */ + const transitionNodes = new Uint32Array(maxNodes * ALPHABET_SIZE); + + // Storing the range of the original string that each node represents: + const rangeStart = new Uint32Array(maxNodes); + const rangeEnd = new Uint32Array(maxNodes); + + const parent = new Uint32Array(maxNodes); + const suffixLink = new Uint32Array(maxNodes); + + let currentNode = 1; + let currentPosition = 1; + let nodeCounter = 3; + let currentIndex = 1; + + function initializeTree() { + rangeEnd.fill(numericSearchValues.length); + rangeEnd[1] = 0; + rangeEnd[2] = 0; + suffixLink[1] = 2; + for (let i = 0; i < ALPHABET_SIZE; ++i) { + transitionNodes[ALPHABET_SIZE * 2 + i] = 1; + } + } + + function processCharacter(char: number) { + // eslint-disable-next-line no-constant-condition + while (true) { + if (rangeEnd[currentNode] < currentPosition) { + if (transitionNodes[currentNode * ALPHABET_SIZE + char] === 0) { + createNewLeaf(char); + continue; + } + currentNode = transitionNodes[currentNode * ALPHABET_SIZE + char]; + currentPosition = rangeStart[currentNode]; + } + if (currentPosition === 0 || char === numericSearchValues[currentPosition]) { + currentPosition++; + } else { + splitEdge(char); + continue; + } + break; + } + } + + function createNewLeaf(c: number) { + transitionNodes[currentNode * ALPHABET_SIZE + c] = nodeCounter; + rangeStart[nodeCounter] = currentIndex; + parent[nodeCounter++] = currentNode; + currentNode = suffixLink[currentNode]; + + currentPosition = rangeEnd[currentNode] + 1; + } + + function splitEdge(c: number) { + rangeStart[nodeCounter] = rangeStart[currentNode]; + rangeEnd[nodeCounter] = currentPosition - 1; + parent[nodeCounter] = parent[currentNode]; + + transitionNodes[nodeCounter * ALPHABET_SIZE + numericSearchValues[currentPosition]] = currentNode; + transitionNodes[nodeCounter * ALPHABET_SIZE + c] = nodeCounter + 1; + rangeStart[nodeCounter + 1] = currentIndex; + parent[nodeCounter + 1] = nodeCounter; + rangeStart[currentNode] = currentPosition; + parent[currentNode] = nodeCounter; + + transitionNodes[parent[nodeCounter] * ALPHABET_SIZE + numericSearchValues[rangeStart[nodeCounter]]] = nodeCounter; + nodeCounter += 2; + handleDescent(nodeCounter); + } + + function handleDescent(latestNodeIndex: number) { + currentNode = suffixLink[parent[latestNodeIndex - 2]]; + currentPosition = rangeStart[latestNodeIndex - 2]; + while (currentPosition <= rangeEnd[latestNodeIndex - 2]) { + currentNode = transitionNodes[currentNode * ALPHABET_SIZE + numericSearchValues[currentPosition]]; + currentPosition += rangeEnd[currentNode] - rangeStart[currentNode] + 1; + } + if (currentPosition === rangeEnd[latestNodeIndex - 2] + 1) { + suffixLink[latestNodeIndex - 2] = currentNode; + } else { + suffixLink[latestNodeIndex - 2] = latestNodeIndex; + } + currentPosition = rangeEnd[currentNode] - (currentPosition - rangeEnd[latestNodeIndex - 2]) + 2; + } + + function build() { + initializeTree(); + for (currentIndex = 1; currentIndex < numericSearchValues.length; ++currentIndex) { + const c = numericSearchValues[currentIndex]; + processCharacter(c); + } + } + + /** + * Returns all occurrences of the given (sub)string in the input string. + * + * You can think of the tree that we create as a big string that looks like this: + * + * "banana$pancake$apple|" + * The example delimiter character '$' is used to separate the different strings. + * The end character '|' is used to indicate the end of our search string. + * + * This function will return the index(es) of found occurrences within this big string. + * So, when searching for "an", it would return [1, 3, 8]. + */ + function findSubstring(searchValue: number[]) { + const occurrences: number[] = []; + + function dfs(node: number, depth: number) { + const leftRange = rangeStart[node]; + const rightRange = rangeEnd[node]; + const rangeLen = node === 1 ? 0 : rightRange - leftRange + 1; + + for (let i = 0; i < rangeLen && depth + i < searchValue.length && leftRange + i < numericSearchValues.length; i++) { + if (searchValue[depth + i] !== numericSearchValues[leftRange + i]) { + return; + } + } + + let isLeaf = true; + for (let i = 0; i < ALPHABET_SIZE; ++i) { + const tNode = transitionNodes[node * ALPHABET_SIZE + i]; + + // Search speed optimization: don't go through the edge if it's different than the next char: + const correctChar = depth + rangeLen >= searchValue.length || i === searchValue[depth + rangeLen]; + + if (tNode !== 0 && tNode !== 1 && correctChar) { + isLeaf = false; + dfs(tNode, depth + rangeLen); + } + } + + if (isLeaf && depth + rangeLen >= searchValue.length) { + occurrences.push(numericSearchValues.length - (depth + rangeLen) + 1); + } + } + + dfs(1, 0); + return occurrences; + } + + return { + build, + findSubstring, + }; +} + +const SuffixUkkonenTree = { + makeTree, + + // Re-exported from utils: + DELIMITER_CHAR_CODE, + SPECIAL_CHAR_CODE, + END_CHAR_CODE, + stringToNumeric, +}; + +export default SuffixUkkonenTree; diff --git a/src/libs/SuffixUkkonenTree/utils.ts b/src/libs/SuffixUkkonenTree/utils.ts new file mode 100644 index 000000000000..96ee35b15796 --- /dev/null +++ b/src/libs/SuffixUkkonenTree/utils.ts @@ -0,0 +1,115 @@ +/* eslint-disable rulesdir/prefer-at */ // .at() has a performance overhead we explicitly want to avoid here +/* eslint-disable no-continue */ + +const CHAR_CODE_A = 'a'.charCodeAt(0); +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'; +const LETTER_ALPHABET_SIZE = ALPHABET.length; +const ALPHABET_SIZE = LETTER_ALPHABET_SIZE + 3; // +3: special char, delimiter char, end char +const SPECIAL_CHAR_CODE = ALPHABET_SIZE - 3; +const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2; +const END_CHAR_CODE = ALPHABET_SIZE - 1; + +// Store the results for a char code in a lookup table to avoid recalculating the same values (performance optimization) +const base26LookupTable = new Array(); + +/** + * Converts a number to a base26 representation. + */ +function convertToBase26(num: number): number[] { + if (base26LookupTable[num]) { + return base26LookupTable[num]; + } + if (num < 0) { + throw new Error('convertToBase26: Input must be a non-negative integer'); + } + + const result: number[] = []; + + do { + // eslint-disable-next-line no-param-reassign + num--; + result.unshift(num % 26); + // eslint-disable-next-line no-bitwise, no-param-reassign + num >>= 5; // Equivalent to Math.floor(num / 26), but faster + } while (num > 0); + + base26LookupTable[num] = result; + return result; +} + +/** + * Converts a string to an array of numbers representing the characters of the string. + * Every number in the array is in the range [0, ALPHABET_SIZE-1] (0-28). + * + * The numbers are offset by the character code of 'a' (97). + * - This is so that the numbers from a-z are in the range 0-28. + * - 26 is for encoding special characters. Character numbers that are not within the range of a-z will be encoded as "specialCharacter + base26(charCode)" + * - 27 is for the delimiter character + * - 28 is for the end character + * + * Note: The string should be converted to lowercase first (otherwise uppercase letters get base26'ed taking more space than necessary). + */ +function stringToNumeric( + // The string we want to convert to a numeric representation + input: string, + options?: { + // A set of characters that should be skipped and not included in the numeric representation + charSetToSkip?: Set; + // When out is provided, the function will write the result to the provided arrays instead of creating new ones (performance) + out?: { + outArray: Uint8Array; + // As outArray is a ArrayBuffer we need to keep track of the current offset + offset: {value: number}; + // A map of to map the found occurrences to the correct data set + // As the search string can be very long for high traffic accounts (500k+), this has to be big enough, thus its a Uint32Array + outOccurrenceToIndex?: Uint32Array; + // The index that will be used in the outOccurrenceToIndex array (this is the index of your original data position) + index?: number; + }; + // By default false. By default the outArray may be larger than necessary. If clamp is set to true the outArray will be clamped to the actual size. + clamp?: boolean; + }, +): { + numeric: Uint8Array; + occurrenceToIndex: Uint32Array; + offset: {value: number}; +} { + // The out array might be longer than our input string length, because we encode special characters as multiple numbers using the base26 encoding. + // * 6 is because the upper limit of encoding any char in UTF-8 to base26 is at max 6 numbers. + const outArray = options?.out?.outArray ?? new Uint8Array(input.length * 6); + const offset = options?.out?.offset ?? {value: 0}; + const occurrenceToIndex = options?.out?.outOccurrenceToIndex ?? new Uint32Array(input.length * 16 * 4); + const index = options?.out?.index ?? 0; + + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + if (options?.charSetToSkip?.has(char)) { + continue; + } + + if (char >= 'a' && char <= 'z') { + // char is an alphabet character + occurrenceToIndex[offset.value] = index; + outArray[offset.value++] = char.charCodeAt(0) - CHAR_CODE_A; + } else { + const charCode = input.charCodeAt(i); + occurrenceToIndex[offset.value] = index; + outArray[offset.value++] = SPECIAL_CHAR_CODE; + const asBase26Numeric = convertToBase26(charCode); + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let j = 0; j < asBase26Numeric.length; j++) { + occurrenceToIndex[offset.value] = index; + outArray[offset.value++] = asBase26Numeric[j]; + } + } + } + + return { + numeric: options?.clamp ? outArray.slice(0, offset.value) : outArray, + occurrenceToIndex, + offset, + }; +} + +export {stringToNumeric, ALPHABET, ALPHABET_SIZE, SPECIAL_CHAR_CODE, DELIMITER_CHAR_CODE, END_CHAR_CODE}; diff --git a/tests/unit/FastSearchTest.ts b/tests/unit/FastSearchTest.ts new file mode 100644 index 000000000000..029e05e15b1f --- /dev/null +++ b/tests/unit/FastSearchTest.ts @@ -0,0 +1,118 @@ +import FastSearch from '../../src/libs/FastSearch'; + +describe('FastSearch', () => { + it('should insert, and find the word', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana'], + toSearchableString: (data) => data, + }, + ]); + expect(search('an')).toEqual([['banana']]); + }); + + it('should work with multiple words', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana', 'test'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('es')).toEqual([['test']]); + }); + + it('should work when providing two data sets', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['erica', 'banana'], + toSearchableString: (data) => data, + }, + { + data: ['banana', 'test'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('es')).toEqual([[], ['test']]); + }); + + it('should work with numbers', () => { + const {search} = FastSearch.createFastSearch([ + { + data: [1, 2, 3, 4, 5], + toSearchableString: (data) => String(data), + }, + ]); + + expect(search('2')).toEqual([[2]]); + }); + + it('should work with unicodes', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana', 'ñèşťǒř', 'test'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('èşť')).toEqual([['ñèşťǒř']]); + }); + + it('should work with words containing "reserved special characters"', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['ba|nana', 'te{st', 'he}llo'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('st')).toEqual([['te{st']]); + expect(search('llo')).toEqual([['he}llo']]); + expect(search('nana')).toEqual([['ba|nana']]); + }); + + it('should be case insensitive', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['banana', 'TeSt', 'TEST', 'X'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('test')).toEqual([['TeSt', 'TEST']]); + }); + + it('should work with large random data sets', () => { + const data = Array.from({length: 1000}, () => { + return Array.from({length: Math.floor(Math.random() * 22 + 9)}, () => { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789@-_.'; + return alphabet.charAt(Math.floor(Math.random() * alphabet.length)); + }).join(''); + }); + + const {search} = FastSearch.createFastSearch([ + { + data, + toSearchableString: (x) => x, + }, + ]); + + data.forEach((word) => { + expect(search(word)).toEqual([expect.arrayContaining([word])]); + }); + }); + + it('should find email addresses without dots', () => { + const {search} = FastSearch.createFastSearch([ + { + data: ['test.user@example.com', 'unrelated'], + toSearchableString: (data) => data, + }, + ]); + + expect(search('testuser')).toEqual([['test.user@example.com']]); + expect(search('test.user')).toEqual([['test.user@example.com']]); + expect(search('examplecom')).toEqual([['test.user@example.com']]); + }); +}); diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts new file mode 100644 index 000000000000..c0c556c16e14 --- /dev/null +++ b/tests/unit/SuffixUkkonenTreeTest.ts @@ -0,0 +1,63 @@ +import SuffixUkkonenTree from '@libs/SuffixUkkonenTree/index'; + +describe('SuffixUkkonenTree', () => { + // The suffix tree doesn't take strings, but expects an array buffer, where strings have been separated by a delimiter. + function helperStringsToNumericForTree(strings: string[]) { + const numericLists = strings.map((s) => SuffixUkkonenTree.stringToNumeric(s, {clamp: true})); + const numericList = numericLists.reduce( + (acc, {numeric}) => { + acc.push(...numeric, SuffixUkkonenTree.DELIMITER_CHAR_CODE); + return acc; + }, + // The value we pass to makeTree needs to be offset by one + [0], + ); + numericList.push(SuffixUkkonenTree.END_CHAR_CODE); + return Uint8Array.from(numericList); + } + + it('should insert, build, and find all occurrences', () => { + const strings = ['banana', 'pancake']; + const numericIntArray = helperStringsToNumericForTree(strings); + + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9])); + }); + + it('should find by first character', () => { + const strings = ['pancake', 'banana']; + const numericIntArray = helperStringsToNumericForTree(strings); + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('p', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([1])); + }); + + it('should handle identical words', () => { + const strings = ['banana', 'banana', 'x']; + const numericIntArray = helperStringsToNumericForTree(strings); + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9, 11])); + }); + + it('should convert string to numeric with a list of chars to skip', () => { + const {numeric} = SuffixUkkonenTree.stringToNumeric('abcabc', { + charSetToSkip: new Set(['b']), + clamp: true, + }); + expect(Array.from(numeric)).toEqual([0, 2, 0, 2]); + }); + + it('should convert string outside of a-z to numeric with clamping', () => { + const {numeric} = SuffixUkkonenTree.stringToNumeric('2', { + clamp: true, + }); + + // "2" in ASCII is 50, so base26(50) = [0, 23] + expect(Array.from(numeric)).toEqual([SuffixUkkonenTree.SPECIAL_CHAR_CODE, 0, 23]); + }); +}); From caa7dc5fcba285de19651acdf38900325c7b5874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 24 Oct 2024 10:53:58 +0200 Subject: [PATCH 054/593] exclude comma from search values --- src/libs/FastSearch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/FastSearch.ts b/src/libs/FastSearch.ts index 59d28dedd449..d514c269320c 100644 --- a/src/libs/FastSearch.ts +++ b/src/libs/FastSearch.ts @@ -16,7 +16,7 @@ type SearchableData = { }; // There are certain characters appear very often in our search data (email addresses), which we don't need to search for. -const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ']); +const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ', ',']); /** * Creates a new "FastSearch" instance. "FastSearch" uses a suffix tree to search for substrings in a list of strings. From a2d8012769451cccaea1e27f2ddf4154abcad201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 5 Nov 2024 09:59:18 +0100 Subject: [PATCH 055/593] wip: refactoring test to be reusable --- tests/unit/OptionsListUtilsTest.ts | 741 +------------------------- tests/utils/OptionListUtilsHelper.ts | 765 +++++++++++++++++++++++++++ 2 files changed, 783 insertions(+), 723 deletions(-) create mode 100644 tests/utils/OptionListUtilsHelper.ts diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 5a0cd6638a07..76ed984d9194 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -1,405 +1,37 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import type {SelectedTagOption} from '@components/TagPicker'; -import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; import * as OptionsListUtils from '@src/libs/OptionsListUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; +import type {Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {PersonalDetailsList} from '../utils/OptionListUtilsHelper'; +import createOptionsListUtilsHelper from '../utils/OptionListUtilsHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -type PersonalDetailsList = Record; - describe('OptionsListUtils', () => { - // Given a set of reports with both single participants and multiple participants some pinned and some not - const REPORTS: OnyxCollection = { - '1': { - lastReadTime: '2021-01-14 11:25:39.295', - lastVisibleActionCreated: '2022-11-22 03:26:02.015', - isPinned: false, - reportID: '1', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Iron Man, Mister Fantastic, Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '2': { - lastReadTime: '2021-01-14 11:25:39.296', - lastVisibleActionCreated: '2022-11-22 03:26:02.016', - isPinned: false, - reportID: '2', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This is the only report we are pinning in this test - '3': { - lastReadTime: '2021-01-14 11:25:39.297', - lastVisibleActionCreated: '2022-11-22 03:26:02.170', - isPinned: true, - reportID: '3', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Fantastic', - type: CONST.REPORT.TYPE.CHAT, - }, - '4': { - lastReadTime: '2021-01-14 11:25:39.298', - lastVisibleActionCreated: '2022-11-22 03:26:02.180', - isPinned: false, - reportID: '4', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Black Panther', - type: CONST.REPORT.TYPE.CHAT, - }, - '5': { - lastReadTime: '2021-01-14 11:25:39.299', - lastVisibleActionCreated: '2022-11-22 03:26:02.019', - isPinned: false, - reportID: '5', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '6': { - lastReadTime: '2021-01-14 11:25:39.300', - lastVisibleActionCreated: '2022-11-22 03:26:02.020', - isPinned: false, - reportID: '6', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Thor', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has the largest lastVisibleActionCreated - '7': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:03.999', - isPinned: false, - reportID: '7', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Captain America', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has no lastVisibleActionCreated - '8': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '8', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Silver Surfer', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has an IOU - '9': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.998', - isPinned: false, - reportID: '9', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Sinister', - iouReportID: '100', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This report is an archived room – it does not have a name and instead falls back on oldPolicyName - '10': { - lastReadTime: '2021-01-14 11:25:39.200', - lastVisibleActionCreated: '2022-11-22 03:26:02.001', - reportID: '10', - isPinned: false, - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: "SHIELD's workspace", - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - - // This indicates that the report is archived - stateNum: 2, - statusNum: 2, - // eslint-disable-next-line @typescript-eslint/naming-convention - private_isArchived: DateUtils.getDBTime(), - }, - }; - - // And a set of personalDetails some with existing reports and some without - const PERSONAL_DETAILS: PersonalDetailsList = { - // These exist in our reports - '1': { - accountID: 1, - displayName: 'Mister Fantastic', - login: 'reedrichards@expensify.com', - isSelected: true, - reportID: '1', - }, - '2': { - accountID: 2, - displayName: 'Iron Man', - login: 'tonystark@expensify.com', - reportID: '1', - }, - '3': { - accountID: 3, - displayName: 'Spider-Man', - login: 'peterparker@expensify.com', - reportID: '1', - }, - '4': { - accountID: 4, - displayName: 'Black Panther', - login: 'tchalla@expensify.com', - reportID: '1', - }, - '5': { - accountID: 5, - displayName: 'Invisible Woman', - login: 'suestorm@expensify.com', - reportID: '1', - }, - '6': { - accountID: 6, - displayName: 'Thor', - login: 'thor@expensify.com', - reportID: '1', - }, - '7': { - accountID: 7, - displayName: 'Captain America', - login: 'steverogers@expensify.com', - reportID: '1', - }, - '8': { - accountID: 8, - displayName: 'Mr Sinister', - login: 'mistersinister@marauders.com', - reportID: '1', - }, - - // These do not exist in reports at all - '9': { - accountID: 9, - displayName: 'Black Widow', - login: 'natasharomanoff@expensify.com', - reportID: '', - }, - '10': { - accountID: 10, - displayName: 'The Incredible Hulk', - login: 'brucebanner@expensify.com', - reportID: '', - }, - }; - - const REPORTS_WITH_CONCIERGE: OnyxCollection = { - ...REPORTS, - - '11': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '11', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Concierge', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_CHRONOS: OnyxCollection = { - ...REPORTS, - '12': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '12', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Chronos', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_RECEIPTS: OnyxCollection = { - ...REPORTS, - '13': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '13', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Receipts', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { - ...REPORTS, - '14': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '14', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: 'Avengers Room', - chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { - ...REPORTS, - 15: { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '15', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man, Black Panther', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, - }, - }; - - const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { - ...PERSONAL_DETAILS, - '999': { - accountID: 999, - displayName: 'Concierge', - login: 'concierge@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1000': { - accountID: 1000, - displayName: 'Chronos', - login: 'chronos@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1001': { - accountID: 1001, - displayName: 'Receipts', - login: 'receipts@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1002': { - accountID: 1002, - displayName: 'The Flash', - login: 'barry.allen@expensify.com', - reportID: '', - }, - }; - - const policyID = 'ABC123'; - - const POLICY: Policy = { - id: policyID, - name: 'Hero Policy', - role: 'user', - type: CONST.POLICY.TYPE.TEAM, - owner: '', - outputCurrency: '', - isPolicyExpenseChatEnabled: false, - }; - - // Set the currently logged in user, report data, and personal details - beforeAll(() => { - Onyx.init({ - keys: ONYXKEYS, - initialKeyStates: { - [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, - [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { - reportID: '', - ownerAccountID: 8, - total: 1000, - }, - [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, - }, - }); - Onyx.registerLogger(() => {}); - return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); - }); - let OPTIONS: OptionsListUtils.OptionList; let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; + const optionListUtilsHelper = createOptionsListUtilsHelper(); + + beforeAll(() => { + return optionListUtilsHelper.beforeAll(); + }); + beforeEach(() => { - OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); - OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); - OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); - OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); - OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); + const options = optionListUtilsHelper.generateOptions(); + OPTIONS = options.OPTIONS; + OPTIONS_WITH_CONCIERGE = options.OPTIONS_WITH_CONCIERGE; + OPTIONS_WITH_CHRONOS = options.OPTIONS_WITH_CHRONOS; + OPTIONS_WITH_RECEIPTS = options.OPTIONS_WITH_RECEIPTS; + OPTIONS_WITH_WORKSPACE_ROOM = options.OPTIONS_WITH_WORKSPACE_ROOM; }); it('getSearchOptions()', () => { @@ -2544,7 +2176,7 @@ describe('OptionsListUtils', () => { }); it('formatMemberForList()', () => { - const formattedMembers = Object.values(PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); + const formattedMembers = Object.values(optionListUtilsHelper.PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); // We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array expect(formattedMembers.at(0)?.text).toBe('Mister Fantastic'); @@ -2561,344 +2193,7 @@ describe('OptionsListUtils', () => { expect(formattedMembers.every((personalDetail) => !personalDetail.isDisabled)).toBe(true); }); - describe('filterOptions', () => { - it('should return all options when search is empty', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, ''); - - expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); - }); - - it('should return filtered options in correct order', () => { - const searchText = 'man'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(4); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); - expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should filter users by email', () => { - const searchText = 'mistersinister@marauders.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); - }); - - it('should find archived chats', () => { - const searchText = 'Archived'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); - }); - - it('should filter options by email if dot is skipped in the email', () => { - const searchText = 'barryallen'; - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); - }); - - it('should include workspace rooms in the search results', () => { - const searchText = 'avengers'; - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); - }); - - it('should put exact match by login on the top of the list', () => { - const searchText = 'reedrichards@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); - }); - - it('should prioritize options with matching display name over chatrooms', () => { - const searchText = 'spider'; - const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); - - const filterOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filterOptions.recentReports.length).toBe(2); - expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); - }); - - it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { - const searchText = 'fantastic'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should return the user to invite when the search value is a valid, non-existent email', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should not return any results if the search value is on an exluded logins list', () => { - const searchText = 'admin@expensify.com'; - - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); - const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - expect(filterOptions.recentReports.length).toBe(0); - }); - - it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should return limited amount of recent reports if the limit is set', () => { - const searchText = ''; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); - - expect(filteredOptions.recentReports.length).toBe(2); - }); - - it('should not return any user to invite if email exists on the personal details list', () => { - const searchText = 'natasharomanoff@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); - }); - - it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { - // Filter current REPORTS as we do in the component, before getting share destination options - const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { - const report = option.item; - if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); - - expect(filteredOptions.recentReports.length).toBe(1); - }); - - it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); - - expect(filteredOptions.recentReports.length).toBe(0); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - }); - - it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); - }); - - it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); - }); - - it('should return matching option when searching (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - }); - - it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - - return waitForBatchedUpdates() - .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) - .then(() => { - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); - const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); - - expect(filteredResults.recentReports.length).toBe(1); - expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); - }); - }); - }); + describe('filterOptions', () => optionListUtilsHelper.createFilterTests()); describe('canCreateOptimisticPersonalDetailOption', () => { const VALID_EMAIL = 'valid@email.com'; diff --git a/tests/utils/OptionListUtilsHelper.ts b/tests/utils/OptionListUtilsHelper.ts new file mode 100644 index 000000000000..d5055aebc5e5 --- /dev/null +++ b/tests/utils/OptionListUtilsHelper.ts @@ -0,0 +1,765 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import Onyx from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; +import * as OptionsListUtils from '@src/libs/OptionsListUtils'; +import * as ReportUtils from '@src/libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; +import waitForBatchedUpdates from './waitForBatchedUpdates'; + +type PersonalDetailsList = Record; + +const createOptionsListUtilsHelper = () => { + // Given a set of reports with both single participants and multiple participants some pinned and some not + const REPORTS: OnyxCollection = { + '1': { + lastReadTime: '2021-01-14 11:25:39.295', + lastVisibleActionCreated: '2022-11-22 03:26:02.015', + isPinned: false, + reportID: '1', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Iron Man, Mister Fantastic, Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '2': { + lastReadTime: '2021-01-14 11:25:39.296', + lastVisibleActionCreated: '2022-11-22 03:26:02.016', + isPinned: false, + reportID: '2', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This is the only report we are pinning in this test + '3': { + lastReadTime: '2021-01-14 11:25:39.297', + lastVisibleActionCreated: '2022-11-22 03:26:02.170', + isPinned: true, + reportID: '3', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Fantastic', + type: CONST.REPORT.TYPE.CHAT, + }, + '4': { + lastReadTime: '2021-01-14 11:25:39.298', + lastVisibleActionCreated: '2022-11-22 03:26:02.180', + isPinned: false, + reportID: '4', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Black Panther', + type: CONST.REPORT.TYPE.CHAT, + }, + '5': { + lastReadTime: '2021-01-14 11:25:39.299', + lastVisibleActionCreated: '2022-11-22 03:26:02.019', + isPinned: false, + reportID: '5', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '6': { + lastReadTime: '2021-01-14 11:25:39.300', + lastVisibleActionCreated: '2022-11-22 03:26:02.020', + isPinned: false, + reportID: '6', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Thor', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has the largest lastVisibleActionCreated + '7': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:03.999', + isPinned: false, + reportID: '7', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Captain America', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has no lastVisibleActionCreated + '8': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '8', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Silver Surfer', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has an IOU + '9': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.998', + isPinned: false, + reportID: '9', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Sinister', + iouReportID: '100', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This report is an archived room – it does not have a name and instead falls back on oldPolicyName + '10': { + lastReadTime: '2021-01-14 11:25:39.200', + lastVisibleActionCreated: '2022-11-22 03:26:02.001', + reportID: '10', + isPinned: false, + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: "SHIELD's workspace", + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + + // This indicates that the report is archived + stateNum: 2, + statusNum: 2, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), + }, + }; + + // And a set of personalDetails some with existing reports and some without + const PERSONAL_DETAILS: PersonalDetailsList = { + // These exist in our reports + '1': { + accountID: 1, + displayName: 'Mister Fantastic', + login: 'reedrichards@expensify.com', + isSelected: true, + reportID: '1', + }, + '2': { + accountID: 2, + displayName: 'Iron Man', + login: 'tonystark@expensify.com', + reportID: '1', + }, + '3': { + accountID: 3, + displayName: 'Spider-Man', + login: 'peterparker@expensify.com', + reportID: '1', + }, + '4': { + accountID: 4, + displayName: 'Black Panther', + login: 'tchalla@expensify.com', + reportID: '1', + }, + '5': { + accountID: 5, + displayName: 'Invisible Woman', + login: 'suestorm@expensify.com', + reportID: '1', + }, + '6': { + accountID: 6, + displayName: 'Thor', + login: 'thor@expensify.com', + reportID: '1', + }, + '7': { + accountID: 7, + displayName: 'Captain America', + login: 'steverogers@expensify.com', + reportID: '1', + }, + '8': { + accountID: 8, + displayName: 'Mr Sinister', + login: 'mistersinister@marauders.com', + reportID: '1', + }, + + // These do not exist in reports at all + '9': { + accountID: 9, + displayName: 'Black Widow', + login: 'natasharomanoff@expensify.com', + reportID: '', + }, + '10': { + accountID: 10, + displayName: 'The Incredible Hulk', + login: 'brucebanner@expensify.com', + reportID: '', + }, + }; + + const REPORTS_WITH_CONCIERGE: OnyxCollection = { + ...REPORTS, + + '11': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '11', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Concierge', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_CHRONOS: OnyxCollection = { + ...REPORTS, + '12': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '12', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Chronos', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_RECEIPTS: OnyxCollection = { + ...REPORTS, + '13': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '13', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Receipts', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { + ...REPORTS, + '14': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '14', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: 'Avengers Room', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { + ...PERSONAL_DETAILS, + '999': { + accountID: 999, + displayName: 'Concierge', + login: 'concierge@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1000': { + accountID: 1000, + displayName: 'Chronos', + login: 'chronos@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1001': { + accountID: 1001, + displayName: 'Receipts', + login: 'receipts@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1002': { + accountID: 1002, + displayName: 'The Flash', + login: 'barry.allen@expensify.com', + reportID: '', + }, + }; + + const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { + ...REPORTS, + 15: { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '15', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man, Black Panther', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + }, + }; + + const policyID = 'ABC123'; + + const POLICY: Policy = { + id: policyID, + name: 'Hero Policy', + role: 'user', + type: CONST.POLICY.TYPE.TEAM, + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }; + + // Set the currently logged in user, report data, and personal details + const beforeAll = () => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, + [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { + reportID: '', + ownerAccountID: 8, + total: 1000, + }, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, + }, + }); + Onyx.registerLogger(() => {}); + return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); + }; + + // TODO: duplicate code? + let OPTIONS: OptionsListUtils.OptionList; + let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; + let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; + let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; + let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; + + const generateOptions = () => { + OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); + OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); + OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); + OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); + OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); + + return { + OPTIONS, + OPTIONS_WITH_CONCIERGE, + OPTIONS_WITH_CHRONOS, + OPTIONS_WITH_RECEIPTS, + OPTIONS_WITH_WORKSPACE_ROOM, + }; + }; + + // TODO: our search is basically just for this use-case: filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); + const createFilterTests = () => { + it('should return all options when search is empty', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, ''); + + expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); + }); + + it('should return filtered options in correct order', () => { + const searchText = 'man'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(4); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); + expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should filter users by email', () => { + const searchText = 'mistersinister@marauders.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); + }); + + it('should find archived chats', () => { + const searchText = 'Archived'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); + }); + + it('should filter options by email if dot is skipped in the email', () => { + const searchText = 'barryallen'; + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); + }); + + it('should include workspace rooms in the search results', () => { + const searchText = 'avengers'; + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); + }); + + it('should put exact match by login on the top of the list', () => { + const searchText = 'reedrichards@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); + }); + + it('should prioritize options with matching display name over chatrooms', () => { + const searchText = 'spider'; + const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); + + const filterOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filterOptions.recentReports.length).toBe(2); + expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); + }); + + it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { + const searchText = 'fantastic'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should return the user to invite when the search value is a valid, non-existent email', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should not return any results if the search value is on an exluded logins list', () => { + const searchText = 'admin@expensify.com'; + + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); + const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + expect(filterOptions.recentReports.length).toBe(0); + }); + + it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should return limited amount of recent reports if the limit is set', () => { + const searchText = ''; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); + + expect(filteredOptions.recentReports.length).toBe(2); + }); + + it('should not return any user to invite if email exists on the personal details list', () => { + const searchText = 'natasharomanoff@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); + }); + + it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { + // Filter current REPORTS as we do in the component, before getting share destination options + const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { + const report = option.item; + if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); + + expect(filteredOptions.recentReports.length).toBe(1); + }); + + it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); + + expect(filteredOptions.recentReports.length).toBe(0); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + }); + + it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); + }); + + it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); + }); + + it('should return matching option when searching (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + }); + + it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + + return waitForBatchedUpdates() + .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) + .then(() => { + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); + const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); + + expect(filteredResults.recentReports.length).toBe(1); + expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); + }); + }); + }; + + return { + generateOptions, + beforeAll, + createFilterTests, + PERSONAL_DETAILS, + REPORTS, + PERSONAL_DETAILS_WITH_PERIODS, + REPORTS_WITH_CHAT_ROOM, + }; +}; + +export default createOptionsListUtilsHelper; + +export type {PersonalDetailsList}; From 9ed2253d8a2f774698c9ba08905b61671e117efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 5 Nov 2024 10:27:14 +0100 Subject: [PATCH 056/593] Revert "wip: refactoring test to be reusable" This reverts commit a2d8012769451cccaea1e27f2ddf4154abcad201. --- tests/unit/OptionsListUtilsTest.ts | 741 +++++++++++++++++++++++++- tests/utils/OptionListUtilsHelper.ts | 765 --------------------------- 2 files changed, 723 insertions(+), 783 deletions(-) delete mode 100644 tests/utils/OptionListUtilsHelper.ts diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 76ed984d9194..5a0cd6638a07 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -1,37 +1,405 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import Onyx from 'react-native-onyx'; import type {OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import type {SelectedTagOption} from '@components/TagPicker'; +import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; import * as OptionsListUtils from '@src/libs/OptionsListUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; +import type {PersonalDetails, Policy, PolicyCategories, Report, TaxRatesWithDefault, Transaction} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {PersonalDetailsList} from '../utils/OptionListUtilsHelper'; -import createOptionsListUtilsHelper from '../utils/OptionListUtilsHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +type PersonalDetailsList = Record; + describe('OptionsListUtils', () => { + // Given a set of reports with both single participants and multiple participants some pinned and some not + const REPORTS: OnyxCollection = { + '1': { + lastReadTime: '2021-01-14 11:25:39.295', + lastVisibleActionCreated: '2022-11-22 03:26:02.015', + isPinned: false, + reportID: '1', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Iron Man, Mister Fantastic, Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '2': { + lastReadTime: '2021-01-14 11:25:39.296', + lastVisibleActionCreated: '2022-11-22 03:26:02.016', + isPinned: false, + reportID: '2', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This is the only report we are pinning in this test + '3': { + lastReadTime: '2021-01-14 11:25:39.297', + lastVisibleActionCreated: '2022-11-22 03:26:02.170', + isPinned: true, + reportID: '3', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Fantastic', + type: CONST.REPORT.TYPE.CHAT, + }, + '4': { + lastReadTime: '2021-01-14 11:25:39.298', + lastVisibleActionCreated: '2022-11-22 03:26:02.180', + isPinned: false, + reportID: '4', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Black Panther', + type: CONST.REPORT.TYPE.CHAT, + }, + '5': { + lastReadTime: '2021-01-14 11:25:39.299', + lastVisibleActionCreated: '2022-11-22 03:26:02.019', + isPinned: false, + reportID: '5', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Invisible Woman', + type: CONST.REPORT.TYPE.CHAT, + }, + '6': { + lastReadTime: '2021-01-14 11:25:39.300', + lastVisibleActionCreated: '2022-11-22 03:26:02.020', + isPinned: false, + reportID: '6', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Thor', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has the largest lastVisibleActionCreated + '7': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:03.999', + isPinned: false, + reportID: '7', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Captain America', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has no lastVisibleActionCreated + '8': { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '8', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Silver Surfer', + type: CONST.REPORT.TYPE.CHAT, + }, + + // Note: This report has an IOU + '9': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.998', + isPinned: false, + reportID: '9', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Mister Sinister', + iouReportID: '100', + type: CONST.REPORT.TYPE.CHAT, + }, + + // This report is an archived room – it does not have a name and instead falls back on oldPolicyName + '10': { + lastReadTime: '2021-01-14 11:25:39.200', + lastVisibleActionCreated: '2022-11-22 03:26:02.001', + reportID: '10', + isPinned: false, + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: "SHIELD's workspace", + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + + // This indicates that the report is archived + stateNum: 2, + statusNum: 2, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_isArchived: DateUtils.getDBTime(), + }, + }; + + // And a set of personalDetails some with existing reports and some without + const PERSONAL_DETAILS: PersonalDetailsList = { + // These exist in our reports + '1': { + accountID: 1, + displayName: 'Mister Fantastic', + login: 'reedrichards@expensify.com', + isSelected: true, + reportID: '1', + }, + '2': { + accountID: 2, + displayName: 'Iron Man', + login: 'tonystark@expensify.com', + reportID: '1', + }, + '3': { + accountID: 3, + displayName: 'Spider-Man', + login: 'peterparker@expensify.com', + reportID: '1', + }, + '4': { + accountID: 4, + displayName: 'Black Panther', + login: 'tchalla@expensify.com', + reportID: '1', + }, + '5': { + accountID: 5, + displayName: 'Invisible Woman', + login: 'suestorm@expensify.com', + reportID: '1', + }, + '6': { + accountID: 6, + displayName: 'Thor', + login: 'thor@expensify.com', + reportID: '1', + }, + '7': { + accountID: 7, + displayName: 'Captain America', + login: 'steverogers@expensify.com', + reportID: '1', + }, + '8': { + accountID: 8, + displayName: 'Mr Sinister', + login: 'mistersinister@marauders.com', + reportID: '1', + }, + + // These do not exist in reports at all + '9': { + accountID: 9, + displayName: 'Black Widow', + login: 'natasharomanoff@expensify.com', + reportID: '', + }, + '10': { + accountID: 10, + displayName: 'The Incredible Hulk', + login: 'brucebanner@expensify.com', + reportID: '', + }, + }; + + const REPORTS_WITH_CONCIERGE: OnyxCollection = { + ...REPORTS, + + '11': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '11', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Concierge', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_CHRONOS: OnyxCollection = { + ...REPORTS, + '12': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '12', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Chronos', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_RECEIPTS: OnyxCollection = { + ...REPORTS, + '13': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '13', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Receipts', + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { + ...REPORTS, + '14': { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: '14', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: '', + oldPolicyName: 'Avengers Room', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + }, + }; + + const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { + ...REPORTS, + 15: { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '15', + participants: { + 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + reportName: 'Spider-Man, Black Panther', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + }, + }; + + const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { + ...PERSONAL_DETAILS, + '999': { + accountID: 999, + displayName: 'Concierge', + login: 'concierge@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1000': { + accountID: 1000, + displayName: 'Chronos', + login: 'chronos@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1001': { + accountID: 1001, + displayName: 'Receipts', + login: 'receipts@expensify.com', + reportID: '', + }, + }; + + const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { + ...PERSONAL_DETAILS, + + '1002': { + accountID: 1002, + displayName: 'The Flash', + login: 'barry.allen@expensify.com', + reportID: '', + }, + }; + + const policyID = 'ABC123'; + + const POLICY: Policy = { + id: policyID, + name: 'Hero Policy', + role: 'user', + type: CONST.POLICY.TYPE.TEAM, + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }; + + // Set the currently logged in user, report data, and personal details + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, + [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { + reportID: '', + ownerAccountID: 8, + total: 1000, + }, + [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, + }, + }); + Onyx.registerLogger(() => {}); + return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); + }); + let OPTIONS: OptionsListUtils.OptionList; let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; - const optionListUtilsHelper = createOptionsListUtilsHelper(); - - beforeAll(() => { - return optionListUtilsHelper.beforeAll(); - }); - beforeEach(() => { - const options = optionListUtilsHelper.generateOptions(); - OPTIONS = options.OPTIONS; - OPTIONS_WITH_CONCIERGE = options.OPTIONS_WITH_CONCIERGE; - OPTIONS_WITH_CHRONOS = options.OPTIONS_WITH_CHRONOS; - OPTIONS_WITH_RECEIPTS = options.OPTIONS_WITH_RECEIPTS; - OPTIONS_WITH_WORKSPACE_ROOM = options.OPTIONS_WITH_WORKSPACE_ROOM; + OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); + OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); + OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); + OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); + OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); }); it('getSearchOptions()', () => { @@ -2176,7 +2544,7 @@ describe('OptionsListUtils', () => { }); it('formatMemberForList()', () => { - const formattedMembers = Object.values(optionListUtilsHelper.PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); + const formattedMembers = Object.values(PERSONAL_DETAILS).map((personalDetail) => OptionsListUtils.formatMemberForList(personalDetail)); // We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array expect(formattedMembers.at(0)?.text).toBe('Mister Fantastic'); @@ -2193,7 +2561,344 @@ describe('OptionsListUtils', () => { expect(formattedMembers.every((personalDetail) => !personalDetail.isDisabled)).toBe(true); }); - describe('filterOptions', () => optionListUtilsHelper.createFilterTests()); + describe('filterOptions', () => { + it('should return all options when search is empty', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, ''); + + expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); + }); + + it('should return filtered options in correct order', () => { + const searchText = 'man'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(4); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); + expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should filter users by email', () => { + const searchText = 'mistersinister@marauders.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); + }); + + it('should find archived chats', () => { + const searchText = 'Archived'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); + }); + + it('should filter options by email if dot is skipped in the email', () => { + const searchText = 'barryallen'; + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); + }); + + it('should include workspace rooms in the search results', () => { + const searchText = 'avengers'; + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); + }); + + it('should put exact match by login on the top of the list', () => { + const searchText = 'reedrichards@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); + }); + + it('should prioritize options with matching display name over chatrooms', () => { + const searchText = 'spider'; + const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); + + const filterOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filterOptions.recentReports.length).toBe(2); + expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); + }); + + it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { + const searchText = 'fantastic'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + }); + + it('should return the user to invite when the search value is a valid, non-existent email', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should not return any results if the search value is on an exluded logins list', () => { + const searchText = 'admin@expensify.com'; + + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); + const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + expect(filterOptions.recentReports.length).toBe(0); + }); + + it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { + const searchText = 'test@email.com'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); + + expect(filteredOptions.userToInvite?.login).toBe(searchText); + }); + + it('should return limited amount of recent reports if the limit is set', () => { + const searchText = ''; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); + + expect(filteredOptions.recentReports.length).toBe(2); + }); + + it('should not return any user to invite if email exists on the personal details list', () => { + const searchText = 'natasharomanoff@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { + const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); + }); + + it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { + // Filter current REPORTS as we do in the component, before getting share destination options + const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { + const report = option.item; + if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); + + expect(filteredOptions.recentReports.length).toBe(1); + }); + + it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { + const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { + const report = option.item; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + filtered.push(option); + } + return filtered; + }, []); + + const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); + + expect(filteredOptions.recentReports.length).toBe(0); + }); + + it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); + + expect(filteredOptions.recentReports.length).toBe(0); + + expect(filteredOptions.personalDetails.length).toBe(1); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + }); + + it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); + }); + + it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).not.toBe(null); + expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); + }); + + it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); + + expect(filteredOptions.recentReports.length).toBe(0); + expect(filteredOptions.personalDetails.length).toBe(0); + expect(filteredOptions.userToInvite).toBe(null); + }); + + it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); + + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + expect(filteredOptions.personalDetails.length).toBe(0); + }); + + it('should return all matching reports and personal details (getFilteredOptions)', () => { + const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); + const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); + + expect(filteredOptions.personalDetails.length).toBe(4); + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); + expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); + }); + + it('should return matching option when searching (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); + }); + + it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); + + return waitForBatchedUpdates() + .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) + .then(() => { + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); + const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); + + expect(filteredResults.recentReports.length).toBe(1); + expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); + }); + }); + }); describe('canCreateOptimisticPersonalDetailOption', () => { const VALID_EMAIL = 'valid@email.com'; diff --git a/tests/utils/OptionListUtilsHelper.ts b/tests/utils/OptionListUtilsHelper.ts deleted file mode 100644 index d5055aebc5e5..000000000000 --- a/tests/utils/OptionListUtilsHelper.ts +++ /dev/null @@ -1,765 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import Onyx from 'react-native-onyx'; -import type {OnyxCollection} from 'react-native-onyx'; -import DateUtils from '@libs/DateUtils'; -import CONST from '@src/CONST'; -import * as OptionsListUtils from '@src/libs/OptionsListUtils'; -import * as ReportUtils from '@src/libs/ReportUtils'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; -import waitForBatchedUpdates from './waitForBatchedUpdates'; - -type PersonalDetailsList = Record; - -const createOptionsListUtilsHelper = () => { - // Given a set of reports with both single participants and multiple participants some pinned and some not - const REPORTS: OnyxCollection = { - '1': { - lastReadTime: '2021-01-14 11:25:39.295', - lastVisibleActionCreated: '2022-11-22 03:26:02.015', - isPinned: false, - reportID: '1', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Iron Man, Mister Fantastic, Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '2': { - lastReadTime: '2021-01-14 11:25:39.296', - lastVisibleActionCreated: '2022-11-22 03:26:02.016', - isPinned: false, - reportID: '2', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This is the only report we are pinning in this test - '3': { - lastReadTime: '2021-01-14 11:25:39.297', - lastVisibleActionCreated: '2022-11-22 03:26:02.170', - isPinned: true, - reportID: '3', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Fantastic', - type: CONST.REPORT.TYPE.CHAT, - }, - '4': { - lastReadTime: '2021-01-14 11:25:39.298', - lastVisibleActionCreated: '2022-11-22 03:26:02.180', - isPinned: false, - reportID: '4', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Black Panther', - type: CONST.REPORT.TYPE.CHAT, - }, - '5': { - lastReadTime: '2021-01-14 11:25:39.299', - lastVisibleActionCreated: '2022-11-22 03:26:02.019', - isPinned: false, - reportID: '5', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 5: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Invisible Woman', - type: CONST.REPORT.TYPE.CHAT, - }, - '6': { - lastReadTime: '2021-01-14 11:25:39.300', - lastVisibleActionCreated: '2022-11-22 03:26:02.020', - isPinned: false, - reportID: '6', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 6: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Thor', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has the largest lastVisibleActionCreated - '7': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:03.999', - isPinned: false, - reportID: '7', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Captain America', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has no lastVisibleActionCreated - '8': { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '8', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 12: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Silver Surfer', - type: CONST.REPORT.TYPE.CHAT, - }, - - // Note: This report has an IOU - '9': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.998', - isPinned: false, - reportID: '9', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 8: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Mister Sinister', - iouReportID: '100', - type: CONST.REPORT.TYPE.CHAT, - }, - - // This report is an archived room – it does not have a name and instead falls back on oldPolicyName - '10': { - lastReadTime: '2021-01-14 11:25:39.200', - lastVisibleActionCreated: '2022-11-22 03:26:02.001', - reportID: '10', - isPinned: false, - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 7: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: "SHIELD's workspace", - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - - // This indicates that the report is archived - stateNum: 2, - statusNum: 2, - // eslint-disable-next-line @typescript-eslint/naming-convention - private_isArchived: DateUtils.getDBTime(), - }, - }; - - // And a set of personalDetails some with existing reports and some without - const PERSONAL_DETAILS: PersonalDetailsList = { - // These exist in our reports - '1': { - accountID: 1, - displayName: 'Mister Fantastic', - login: 'reedrichards@expensify.com', - isSelected: true, - reportID: '1', - }, - '2': { - accountID: 2, - displayName: 'Iron Man', - login: 'tonystark@expensify.com', - reportID: '1', - }, - '3': { - accountID: 3, - displayName: 'Spider-Man', - login: 'peterparker@expensify.com', - reportID: '1', - }, - '4': { - accountID: 4, - displayName: 'Black Panther', - login: 'tchalla@expensify.com', - reportID: '1', - }, - '5': { - accountID: 5, - displayName: 'Invisible Woman', - login: 'suestorm@expensify.com', - reportID: '1', - }, - '6': { - accountID: 6, - displayName: 'Thor', - login: 'thor@expensify.com', - reportID: '1', - }, - '7': { - accountID: 7, - displayName: 'Captain America', - login: 'steverogers@expensify.com', - reportID: '1', - }, - '8': { - accountID: 8, - displayName: 'Mr Sinister', - login: 'mistersinister@marauders.com', - reportID: '1', - }, - - // These do not exist in reports at all - '9': { - accountID: 9, - displayName: 'Black Widow', - login: 'natasharomanoff@expensify.com', - reportID: '', - }, - '10': { - accountID: 10, - displayName: 'The Incredible Hulk', - login: 'brucebanner@expensify.com', - reportID: '', - }, - }; - - const REPORTS_WITH_CONCIERGE: OnyxCollection = { - ...REPORTS, - - '11': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '11', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 999: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Concierge', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_CHRONOS: OnyxCollection = { - ...REPORTS, - '12': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '12', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1000: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Chronos', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_RECEIPTS: OnyxCollection = { - ...REPORTS, - '13': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '13', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1001: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Receipts', - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const REPORTS_WITH_WORKSPACE_ROOMS: OnyxCollection = { - ...REPORTS, - '14': { - lastReadTime: '2021-01-14 11:25:39.302', - lastVisibleActionCreated: '2022-11-22 03:26:02.022', - isPinned: false, - reportID: '14', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 1: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: '', - oldPolicyName: 'Avengers Room', - chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, - isOwnPolicyExpenseChat: true, - type: CONST.REPORT.TYPE.CHAT, - }, - }; - - const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { - ...PERSONAL_DETAILS, - '999': { - accountID: 999, - displayName: 'Concierge', - login: 'concierge@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_CHRONOS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1000': { - accountID: 1000, - displayName: 'Chronos', - login: 'chronos@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_RECEIPTS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1001': { - accountID: 1001, - displayName: 'Receipts', - login: 'receipts@expensify.com', - reportID: '', - }, - }; - - const PERSONAL_DETAILS_WITH_PERIODS: PersonalDetailsList = { - ...PERSONAL_DETAILS, - - '1002': { - accountID: 1002, - displayName: 'The Flash', - login: 'barry.allen@expensify.com', - reportID: '', - }, - }; - - const REPORTS_WITH_CHAT_ROOM: OnyxCollection = { - ...REPORTS, - 15: { - lastReadTime: '2021-01-14 11:25:39.301', - lastVisibleActionCreated: '2022-11-22 03:26:02.000', - isPinned: false, - reportID: '15', - participants: { - 2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 3: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - 4: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - reportName: 'Spider-Man, Black Panther', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, - }, - }; - - const policyID = 'ABC123'; - - const POLICY: Policy = { - id: policyID, - name: 'Hero Policy', - role: 'user', - type: CONST.POLICY.TYPE.TEAM, - owner: '', - outputCurrency: '', - isPolicyExpenseChatEnabled: false, - }; - - // Set the currently logged in user, report data, and personal details - const beforeAll = () => { - Onyx.init({ - keys: ONYXKEYS, - initialKeyStates: { - [ONYXKEYS.SESSION]: {accountID: 2, email: 'tonystark@expensify.com'}, - [`${ONYXKEYS.COLLECTION.REPORT}100` as const]: { - reportID: '', - ownerAccountID: 8, - total: 1000, - }, - [`${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const]: POLICY, - }, - }); - Onyx.registerLogger(() => {}); - return waitForBatchedUpdates().then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS)); - }; - - // TODO: duplicate code? - let OPTIONS: OptionsListUtils.OptionList; - let OPTIONS_WITH_CONCIERGE: OptionsListUtils.OptionList; - let OPTIONS_WITH_CHRONOS: OptionsListUtils.OptionList; - let OPTIONS_WITH_RECEIPTS: OptionsListUtils.OptionList; - let OPTIONS_WITH_WORKSPACE_ROOM: OptionsListUtils.OptionList; - - const generateOptions = () => { - OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); - OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); - OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); - OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); - OPTIONS_WITH_WORKSPACE_ROOM = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_WORKSPACE_ROOMS); - - return { - OPTIONS, - OPTIONS_WITH_CONCIERGE, - OPTIONS_WITH_CHRONOS, - OPTIONS_WITH_RECEIPTS, - OPTIONS_WITH_WORKSPACE_ROOM, - }; - }; - - // TODO: our search is basically just for this use-case: filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); - const createFilterTests = () => { - it('should return all options when search is empty', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, ''); - - expect(filteredOptions.recentReports.length + filteredOptions.personalDetails.length).toBe(12); - }); - - it('should return filtered options in correct order', () => { - const searchText = 'man'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(4); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Invisible Woman'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Spider-Man'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Widow'); - expect(filteredOptions.recentReports.at(3)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should filter users by email', () => { - const searchText = 'mistersinister@marauders.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mr Sinister'); - }); - - it('should find archived chats', () => { - const searchText = 'Archived'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(!!filteredOptions.recentReports.at(0)?.private_isArchived).toBe(true); - }); - - it('should filter options by email if dot is skipped in the email', () => { - const searchText = 'barryallen'; - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {sortByReportTypeInSearch: true}); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe('barry.allen@expensify.com'); - }); - - it('should include workspace rooms in the search results', () => { - const searchText = 'avengers'; - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACE_ROOM, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.subtitle).toBe('Avengers Room'); - }); - - it('should put exact match by login on the top of the list', () => { - const searchText = 'reedrichards@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.login).toBe(searchText); - }); - - it('should prioritize options with matching display name over chatrooms', () => { - const searchText = 'spider'; - const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); - const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); - - const filterOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filterOptions.recentReports.length).toBe(2); - expect(filterOptions.recentReports.at(1)?.isChatRoom).toBe(true); - }); - - it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { - const searchText = 'fantastic'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - }); - - it('should return the user to invite when the search value is a valid, non-existent email', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should not return any results if the search value is on an exluded logins list', () => { - const searchText = 'admin@expensify.com'; - - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails, excludeLogins: CONST.EXPENSIFY_EMAILS}); - const filterOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - expect(filterOptions.recentReports.length).toBe(0); - }); - - it('should return the user to invite when the search value is a valid, non-existent email and the user is not excluded', () => { - const searchText = 'test@email.com'; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {excludeLogins: CONST.EXPENSIFY_EMAILS}); - - expect(filteredOptions.userToInvite?.login).toBe(searchText); - }); - - it('should return limited amount of recent reports if the limit is set', () => { - const searchText = ''; - - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, searchText, {maxRecentReportsToShow: 2}); - - expect(filteredOptions.recentReports.length).toBe(2); - }); - - it('should not return any user to invite if email exists on the personal details list', () => { - const searchText = 'natasharomanoff@expensify.com'; - const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); - - const filteredOptions = OptionsListUtils.filterOptions(options, searchText); - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one personal detail if search value matches an email (getMemberInviteOptions)', () => { - const options = OptionsListUtils.getMemberInviteOptions(OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com'); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.text).toBe('Spider-Man'); - }); - - it('should not show any recent reports if a search value does not match the group chat name (getShareDestinationsOptions)', () => { - // Filter current REPORTS as we do in the component, before getting share destination options - const filteredReports = Object.values(OPTIONS.reports).reduce((filtered, option) => { - const report = option.item; - if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - const options = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'mutants'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should return a workspace room when we search for a workspace room(getShareDestinationsOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Avengers Room'); - - expect(filteredOptions.recentReports.length).toBe(1); - }); - - it('should not show any results if searching for a non-existing workspace room(getShareDestinationOptions)', () => { - const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => { - const report = option.item; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (ReportUtils.canUserPerformWriteAction(report) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { - filtered.push(option); - } - return filtered; - }, []); - - const options = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'Mutants Lair'); - - expect(filteredOptions.recentReports.length).toBe(0); - }); - - it('should show the option from personal details when searching for personal detail with no existing report (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'hulk'); - - expect(filteredOptions.recentReports.length).toBe(0); - - expect(filteredOptions.personalDetails.length).toBe(1); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('brucebanner@expensify.com'); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - }); - - it('should not return any options or user to invite if there are no search results and the string does not match a potential email or phone (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options but should return an user to invite if no matching options exist and the search value is a potential email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'marc@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should return user to invite when search term has a period with options for it that do not contain the period (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peter.parker@expensify.com'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '5005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with country code added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+15005550006'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+15005550006'); - }); - - it('should not return options but should return an user to invite if no matching options exist and the search value is a potential phone number with special characters added (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '+1 (800)324-3233'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).not.toBe(null); - expect(filteredOptions.userToInvite?.login).toBe('+18003243233'); - }); - - it('should not return any options or user to invite if contact number contains alphabet characters (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '998243aaaa'); - - expect(filteredOptions.recentReports.length).toBe(0); - expect(filteredOptions.personalDetails.length).toBe(0); - expect(filteredOptions.userToInvite).toBe(null); - }); - - it('should not return any options if search value does not match any personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'magneto'); - - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return one recent report and no personal details if a search value provides an email (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, 'peterparker@expensify.com', {sortByReportTypeInSearch: true}); - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - expect(filteredOptions.personalDetails.length).toBe(0); - }); - - it('should return all matching reports and personal details (getFilteredOptions)', () => { - const options = OptionsListUtils.getFilteredOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}); - const filteredOptions = OptionsListUtils.filterOptions(options, '.com'); - - expect(filteredOptions.personalDetails.length).toBe(4); - expect(filteredOptions.recentReports.length).toBe(5); - expect(filteredOptions.personalDetails.at(0)?.login).toBe('natasharomanoff@expensify.com'); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Captain America'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mr Sinister'); - expect(filteredOptions.recentReports.at(2)?.text).toBe('Black Panther'); - }); - - it('should return matching option when searching (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'spider'); - - expect(filteredOptions.recentReports.length).toBe(1); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Spider-Man'); - }); - - it('should return latest lastVisibleActionCreated item on top when search value matches multiple items (getSearchOptions)', () => { - const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); - const filteredOptions = OptionsListUtils.filterOptions(options, 'fantastic'); - - expect(filteredOptions.recentReports.length).toBe(2); - expect(filteredOptions.recentReports.at(0)?.text).toBe('Mister Fantastic'); - expect(filteredOptions.recentReports.at(1)?.text).toBe('Mister Fantastic, Invisible Woman'); - - return waitForBatchedUpdates() - .then(() => Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, PERSONAL_DETAILS_WITH_PERIODS)) - .then(() => { - const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); - const results = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, ''); - const filteredResults = OptionsListUtils.filterOptions(results, 'barry.allen@expensify.com', {sortByReportTypeInSearch: true}); - - expect(filteredResults.recentReports.length).toBe(1); - expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); - }); - }); - }; - - return { - generateOptions, - beforeAll, - createFilterTests, - PERSONAL_DETAILS, - REPORTS, - PERSONAL_DETAILS_WITH_PERIODS, - REPORTS_WITH_CHAT_ROOM, - }; -}; - -export default createOptionsListUtilsHelper; - -export type {PersonalDetailsList}; From a01a375a7368b2e69603fa4aed5401de48c7c74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Tue, 5 Nov 2024 11:49:28 +0100 Subject: [PATCH 057/593] fix: sort search results correctly --- .../Search/SearchRouter/SearchRouter.tsx | 15 ++++++++++----- src/libs/OptionsListUtils.ts | 14 +++++++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 6f5481a17983..4b1a7a9ed4ad 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -162,19 +162,24 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const newOptions = findInSearchTree(debouncedInputValue); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const recentReports = newOptions.recentReports.concat(newOptions.personalDetails); + // See OptionListUtils.filterOptions#sortByReportTypeInSearch: + const filteredPersonalDetails = OptionsListUtils.filteredPersonalDetailsOfRecentReports(newOptions.recentReports, newOptions.personalDetails); + const recentReportsWithPersonalDetails = newOptions.recentReports.concat(filteredPersonalDetails); + const sortedReports = OptionsListUtils.orderOptions(recentReportsWithPersonalDetails, debouncedInputValue, { + preferChatroomsOverThreads: true, + }); const userToInvite = OptionsListUtils.pickUserToInvite({ canInviteUser: true, - recentReports: newOptions.recentReports, - personalDetails: newOptions.personalDetails, + recentReports: sortedReports, + personalDetails: filteredPersonalDetails, searchValue: debouncedInputValue, optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}], }); return { - recentReports, - personalDetails: [], + recentReports: sortedReports, + personalDetails: filteredPersonalDetails, userToInvite, }; }, [debouncedInputValue, findInSearchTree]); diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index f414d2328ef6..efeca80c1fd1 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2502,6 +2502,14 @@ const pickUserToInvite = ({canInviteUser, recentReports, personalDetails, search return userToInvite; }; +/** + * Remove the personal details for the DMs that are already in the recent reports so that we don't show duplicates + */ +function filteredPersonalDetailsOfRecentReports(recentReports: ReportUtils.OptionData[], personalDetails: ReportUtils.OptionData[]) { + const excludedLogins = new Set(recentReports.map((report) => report.login)); + return personalDetails.filter((personalDetail) => !excludedLogins.has(personalDetail.login)); +} + /** * Filters options based on the search input value */ @@ -2515,11 +2523,6 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt preferPolicyExpenseChat = false, preferRecentExpenseReports = false, } = config ?? {}; - // Remove the personal details for the DMs that are already in the recent reports so that we don't show duplicates - function filteredPersonalDetailsOfRecentReports(recentReports: ReportUtils.OptionData[], personalDetails: ReportUtils.OptionData[]) { - const excludedLogins = new Set(recentReports.map((report) => report.login)); - return personalDetails.filter((personalDetail) => !excludedLogins.has(personalDetail.login)); - } if (searchInputValue.trim() === '' && maxRecentReportsToShow > 0) { const recentReports = options.recentReports.slice(0, maxRecentReportsToShow); const personalDetails = filteredPersonalDetailsOfRecentReports(recentReports, options.personalDetails); @@ -2676,6 +2679,7 @@ export { getAlternateText, pickUserToInvite, hasReportErrors, + filteredPersonalDetailsOfRecentReports, }; export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Tax, TaxRatesOption, Option, OptionTree}; From a162fe71045c28c4d5f1075e27b84e90f3143185 Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Tue, 5 Nov 2024 15:07:15 -0800 Subject: [PATCH 058/593] fix wallet phone validation page --- src/libs/GetPhysicalCardUtils.ts | 6 +++++- .../settings/Wallet/Card/GetPhysicalCardPhone.tsx | 15 +++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts index 8dc46204db3c..ff0c02aff54c 100644 --- a/src/libs/GetPhysicalCardUtils.ts +++ b/src/libs/GetPhysicalCardUtils.ts @@ -1,4 +1,6 @@ +import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; +import * as PhoneNumberUtils from '@libs/PhoneNumber'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {GetPhysicalCardForm} from '@src/types/form'; @@ -11,11 +13,13 @@ import * as UserUtils from './UserUtils'; function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry): Route { const {legalFirstName, legalLastName, phoneNumber} = privatePersonalDetails ?? {}; const address = PersonalDetailsUtils.getCurrentAddress(privatePersonalDetails); + const phoneNumberWithCountryCode = LoginUtils.appendCountryCode(phoneNumber ?? ''); + const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumberWithCountryCode); if (!legalFirstName && !legalLastName) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain); } - if (!phoneNumber || !LoginUtils.validateNumber(phoneNumber)) { + if (!phoneNumber || !parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumberWithCountryCode.slice(0))) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain); } if (!(address?.street && address?.city && address?.state && address?.country && address?.zip)) { diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx index 56d5a29a3203..ad04d9f3fb9c 100644 --- a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx +++ b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx @@ -1,4 +1,5 @@ import type {StackScreenProps} from '@react-navigation/stack'; +import {Str} from 'expensify-common'; import React from 'react'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -6,6 +7,8 @@ import InputWrapper from '@components/Form/InputWrapper'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import * as LoginUtils from '@libs/LoginUtils'; +import * as PhoneNumberUtils from '@libs/PhoneNumber'; +import * as ValidationUtils from '@libs/ValidationUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -40,13 +43,17 @@ function GetPhysicalCardPhone({ const {phoneNumber: phoneNumberToValidate = ''} = values ?? {}; const errors: OnValidateResult = {}; - - if (!LoginUtils.validateNumber(phoneNumberToValidate)) { - errors.phoneNumber = translate('common.error.phoneNumber'); - } else if (!phoneNumberToValidate) { + if (!ValidationUtils.isRequiredFulfilled(phoneNumberToValidate)) { errors.phoneNumber = translate('common.error.fieldRequired'); } + const phoneNumberWithCountryCode = LoginUtils.appendCountryCode(phoneNumberToValidate); + const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumberWithCountryCode); + + if (!parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumberWithCountryCode.slice(0))) { + errors.phoneNumber = translate('bankAccount.error.phoneNumber'); + } + return errors; }; From 8e3babe9a7e599b8f1d18a52d3bfdc77c4d0c464 Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Wed, 6 Nov 2024 22:39:18 -0800 Subject: [PATCH 059/593] fix eslint warning and unnecessary changes --- src/libs/GetPhysicalCardUtils.ts | 2 +- src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts index ff0c02aff54c..9ed192b09233 100644 --- a/src/libs/GetPhysicalCardUtils.ts +++ b/src/libs/GetPhysicalCardUtils.ts @@ -1,6 +1,5 @@ import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; -import * as PhoneNumberUtils from '@libs/PhoneNumber'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {GetPhysicalCardForm} from '@src/types/form'; @@ -8,6 +7,7 @@ import type {LoginList, PrivatePersonalDetails} from '@src/types/onyx'; import * as LoginUtils from './LoginUtils'; import Navigation from './Navigation/Navigation'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as PhoneNumberUtils from './PhoneNumber'; import * as UserUtils from './UserUtils'; function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry): Route { diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx index ad04d9f3fb9c..ce50a224c20d 100644 --- a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx +++ b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.tsx @@ -43,6 +43,7 @@ function GetPhysicalCardPhone({ const {phoneNumber: phoneNumberToValidate = ''} = values ?? {}; const errors: OnValidateResult = {}; + if (!ValidationUtils.isRequiredFulfilled(phoneNumberToValidate)) { errors.phoneNumber = translate('common.error.fieldRequired'); } From 1e02c8257083ae87dccfa30e4315672fb929664d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 7 Nov 2024 10:47:26 +0100 Subject: [PATCH 060/593] cleanup option list --- src/libs/OptionsListUtils.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 099efcc1bfd9..56ae9e94deb7 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2583,13 +2583,15 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt }; }, options); - let {recentReports, personalDetails} = matchResults; + const {recentReports, personalDetails} = matchResults; + const noneReportPersonalDetails = filteredPersonalDetailsOfRecentReports(recentReports, personalDetails); + + let filteredPersonalDetails: ReportUtils.OptionData[] = noneReportPersonalDetails; + let filteredRecentReports: ReportUtils.OptionData[] = recentReports; if (sortByReportTypeInSearch) { - personalDetails = filteredPersonalDetailsOfRecentReports(recentReports, personalDetails); - recentReports = recentReports.concat(personalDetails); - personalDetails = []; - recentReports = orderOptions(recentReports, searchValue); + filteredRecentReports = recentReports.concat(noneReportPersonalDetails); + filteredPersonalDetails = []; } const userToInvite = pickUserToInvite({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}); @@ -2597,11 +2599,11 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt if (maxRecentReportsToShow > 0 && recentReports.length > maxRecentReportsToShow) { recentReports.splice(maxRecentReportsToShow); } - const filteredPersonalDetails = filteredPersonalDetailsOfRecentReports(recentReports, personalDetails); + const sortedRecentReports = orderOptions(filteredRecentReports, searchValue, {preferChatroomsOverThreads, preferPolicyExpenseChat, preferRecentExpenseReports}); return { personalDetails: filteredPersonalDetails, - recentReports: orderOptions(recentReports, searchValue, {preferChatroomsOverThreads, preferPolicyExpenseChat, preferRecentExpenseReports}), + recentReports: sortedRecentReports, userToInvite, currentUserOption: matchResults.currentUserOption, categoryOptions: [], From c73aad5b75f156928209d1236720f0653f96b358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 7 Nov 2024 10:50:30 +0100 Subject: [PATCH 061/593] fix duplicate search results --- src/components/Search/SearchRouter/SearchRouter.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 5d2d00bfeb43..968bb234ff5b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -135,8 +135,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); // See OptionListUtils.filterOptions#sortByReportTypeInSearch: - const filteredPersonalDetails = OptionsListUtils.filteredPersonalDetailsOfRecentReports(newOptions.recentReports, newOptions.personalDetails); - const recentReportsWithPersonalDetails = newOptions.recentReports.concat(filteredPersonalDetails); + const noneReportPersonalDetails = OptionsListUtils.filteredPersonalDetailsOfRecentReports(newOptions.recentReports, newOptions.personalDetails); + const recentReportsWithPersonalDetails = newOptions.recentReports.concat(noneReportPersonalDetails); const sortedReports = OptionsListUtils.orderOptions(recentReportsWithPersonalDetails, debouncedInputValue, { preferChatroomsOverThreads: true, }); @@ -144,14 +144,14 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const userToInvite = OptionsListUtils.pickUserToInvite({ canInviteUser: true, recentReports: sortedReports, - personalDetails: filteredPersonalDetails, + personalDetails: [], searchValue: debouncedInputValue, optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}], }); return { recentReports: sortedReports, - personalDetails: filteredPersonalDetails, + personalDetails: [], userToInvite, }; }, [debouncedInputValue, findInSearchTree]); From 6a7b7e84a2f10cf5749b5c39494129bd05a9b8a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 7 Nov 2024 17:00:20 +0100 Subject: [PATCH 062/593] eslint --- src/components/Search/SearchRouter/SearchRouter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 968bb234ff5b..0c1fd1a9094b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -16,8 +16,8 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import FastSearch from '@libs/FastSearch'; import * as CardUtils from '@libs/CardUtils'; +import FastSearch from '@libs/FastSearch'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; From 253d17b8baab9bad3d6fd97fa8a9cc6551b75980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Fri, 8 Nov 2024 10:16:51 +0100 Subject: [PATCH 063/593] wip --- src/libs/OptionsListUtils.ts | 6 +++++- tests/unit/OptionsListUtilsTest.ts | 24 ++++++++++++++++++++++++ tests/unit/SuffixUkkonenTreeTest.ts | 9 +++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 56ae9e94deb7..fba245b330b1 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1687,7 +1687,11 @@ function getUserToInviteOption({ } /** - * filter options based on specific conditions + * TODO: What is the purpose of this function + * + * - It seems to convert Report & PersonalDetails into a unified format + * - It applies ordering to the items + * - Given a searchValue, it will filter the items */ function getOptions( options: OptionList, diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 5a0cd6638a07..65ef0aebd000 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -225,6 +225,18 @@ describe('OptionsListUtils', () => { login: 'brucebanner@expensify.com', reportID: '', }, + '110': { + accountID: 110, + displayName: 'SubString', + login: 'SubString@mail.com', + reportID: '', + }, + '111': { + accountID: 111, + displayName: 'String', + login: 'String@mail.com', + reportID: '', + }, }; const REPORTS_WITH_CONCIERGE: OnyxCollection = { @@ -396,6 +408,7 @@ describe('OptionsListUtils', () => { beforeEach(() => { OPTIONS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS); + console.log(OPTIONS); OPTIONS_WITH_CONCIERGE = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CONCIERGE, REPORTS_WITH_CONCIERGE); OPTIONS_WITH_CHRONOS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_CHRONOS, REPORTS_WITH_CHRONOS); OPTIONS_WITH_RECEIPTS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_RECEIPTS, REPORTS_WITH_RECEIPTS); @@ -2898,6 +2911,17 @@ describe('OptionsListUtils', () => { expect(filteredResults.recentReports.at(0)?.text).toBe('The Flash'); }); }); + + it('should return prefix match before suffix match', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + console.log(options); + const filteredOptions = OptionsListUtils.filterOptions(options, 'String'); + + console.log(filteredOptions); + expect(filteredOptions.personalDetails.length).toBe(2); + expect(filteredOptions.personalDetails.at(0)?.text).toBe('String'); + expect(filteredOptions.personalDetails.at(1)?.text).toBe('SubString'); + }); }); describe('canCreateOptimisticPersonalDetailOption', () => { diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts index c0c556c16e14..0cf34fff3308 100644 --- a/tests/unit/SuffixUkkonenTreeTest.ts +++ b/tests/unit/SuffixUkkonenTreeTest.ts @@ -60,4 +60,13 @@ describe('SuffixUkkonenTree', () => { // "2" in ASCII is 50, so base26(50) = [0, 23] expect(Array.from(numeric)).toEqual([SuffixUkkonenTree.SPECIAL_CHAR_CODE, 0, 23]); }); + + it('should have prefix matches first, then substring matches', () => { + const strings = ['abcdef', 'cdef', 'def']; + const numericIntArray = helperStringsToNumericForTree(strings); + const tree = SuffixUkkonenTree.makeTree(numericIntArray); + tree.build(); + const searchValue = SuffixUkkonenTree.stringToNumeric('def', {clamp: true}).numeric; + expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([18, 15, 10])); + }); }); From 81cf18979ba9c934d4eed36deb729ef2fd307e4d Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Thu, 14 Nov 2024 16:55:46 +0700 Subject: [PATCH 064/593] Update correct next approver with category/tag rules --- src/libs/ReportUtils.ts | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index b220c2db20b6..5211ea986bd9 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -61,6 +61,7 @@ import * as IOU from './actions/IOU'; import * as PolicyActions from './actions/Policy/Policy'; import * as store from './actions/ReimbursementAccount/store'; import * as SessionUtils from './actions/Session'; +import {getCategoryApproverRule} from './CategoryUtils'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import {hasValidDraftComment} from './DraftCommentUtils'; @@ -6351,7 +6352,7 @@ function shouldDisplayViolationsRBRInLHN(report: OnyxEntry, transactionV // - Belong to the same workspace // And if any have a violation, then it should have a RBR const allReports = Object.values(ReportConnection.getAllReports() ?? {}) as Report[]; - const potentialReports = allReports.filter((r) => r.ownerAccountID === currentUserAccountID && (r.stateNum ?? 0) <= 1 && r.policyID === report.policyID); + const potentialReports = allReports.filter((r) => r?.ownerAccountID === currentUserAccountID && (r.stateNum ?? 0) <= 1 && r.policyID === report.policyID); return potentialReports.some( (potentialReport) => hasViolations(potentialReport.reportID, transactionViolations) || hasWarningTypeViolations(potentialReport.reportID, transactionViolations), ); @@ -8368,9 +8369,47 @@ function isExported(reportActions: OnyxEntry) { return Object.values(reportActions).some((action) => ReportActionsUtils.isExportIntegrationAction(action)); } +function getRuleApprovers(policy: OnyxEntry, expenseReport: OnyxEntry) { + const categoryAppovers: string[] = []; + const tagApprovers: string[] = []; + const allReportTransactions = TransactionUtils.getAllReportTransactions(expenseReport?.reportID).sort((transA, transB) => (transA.created < transB.created ? -1 : 1)); + + // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. + // Category approvers are prioritized, then tag approvers. + for (let i = 0; i < allReportTransactions.length; i++) { + const transaction = allReportTransactions.at(i); + const tag = TransactionUtils.getTag(transaction); + const category = TransactionUtils.getCategory(transaction); + const categoryAppover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; + const tagApprover = PolicyUtils.getTagApproverRule(policy?.id ?? '-1', tag)?.approver; + if (categoryAppover) { + categoryAppovers.push(categoryAppover); + } + + if (tagApprover) { + tagApprovers.push(tagApprover); + } + } + + return [...categoryAppovers, ...tagApprovers]; +} + function getApprovalChain(policy: OnyxEntry, expenseReport: OnyxEntry): string[] { const approvalChain: string[] = []; const reportTotal = expenseReport?.total ?? 0; + const submitterEmail = PersonalDetailsUtils.getLoginsByAccountIDs([expenseReport?.ownerAccountID ?? -1]).at(0) ?? ''; + + // Get category/tag approver list + const ruleApprovers = getRuleApprovers(policy, expenseReport); + + // Push rule approvers to approvalChain list before submitsTo/forwardsTo approvers + ruleApprovers.forEach((ruleApprover) => { + // Don't push submiiter to approve as a rule approver + if (approvalChain.includes(ruleApprover) || ruleApprover === submitterEmail) { + return; + } + approvalChain.push(ruleApprover); + }); // If the policy is not on advanced approval mode, we should not use the approval chain even if it exists. if (!PolicyUtils.isControlOnAdvancedApprovalMode(policy)) { From 50965a73228416b21856b56fdc8dc820f880aadd Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 18 Nov 2024 14:45:18 +0700 Subject: [PATCH 065/593] prevent refocus on closing modal --- .../ComposerWithSuggestions.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 582aabf06c9e..210d3e17bc7d 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -29,6 +29,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; +import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {forceClearInput} from '@libs/ComponentUtils'; import * as ComposerUtils from '@libs/ComposerUtils'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; @@ -624,7 +625,15 @@ function ComposerWithSuggestions( // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. // We avoid doing this on native platforms since the software keyboard popping // open creates a jarring and broken UX. - if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !modal?.isVisible && isFocused && (!!prevIsModalVisible || !prevIsFocused))) { + if ( + !( + (willBlurTextInputOnTapOutside || (shouldAutoFocus && canFocusInputOnScreenFocus())) && + !isNextModalWillOpenRef.current && + !modal?.isVisible && + isFocused && + (!!prevIsModalVisible || !prevIsFocused) + ) + ) { return; } From cb5d8fa9805c0d6749e914d466ea276645de478d Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Tue, 19 Nov 2024 10:52:46 +0100 Subject: [PATCH 066/593] fix: typescript checks --- .../ActionSheetAwareScrollViewContext.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx index 00b662aaba8c..133afbacbd72 100644 --- a/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx +++ b/src/components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext.tsx @@ -20,21 +20,24 @@ type Context = { resetStateMachine: () => void; }; +const currentActionSheetStateValue = { + previous: { + state: 'idle', + payload: null, + }, + current: { + state: 'idle', + payload: null, + }, +}; const defaultValue: Context = { currentActionSheetState: { - value: { - previous: { - state: 'idle', - payload: null, - }, - current: { - state: 'idle', - payload: null, - }, - }, + value: currentActionSheetStateValue, addListener: noop, removeListener: noop, modify: noop, + get: () => currentActionSheetStateValue, + set: noop, }, transitionActionSheetState: noop, transitionActionSheetStateWorklet: noop, From 857262238f1518f5c0ac0461dccf5d1844bb8330 Mon Sep 17 00:00:00 2001 From: christianwen Date: Wed, 20 Nov 2024 15:53:25 +0700 Subject: [PATCH 067/593] fix cursor flash --- .../Composer/implementation/index.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index bf155bfdc04b..cdf43ecd8d90 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -4,7 +4,7 @@ import type {BaseSyntheticEvent, ForwardedRef} from 'react'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native'; -import {DeviceEventEmitter, StyleSheet} from 'react-native'; +import {DeviceEventEmitter, InteractionManager, StyleSheet} from 'react-native'; import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; @@ -72,6 +72,11 @@ function Composer( end: selectionProp.end, }); const [isRendered, setIsRendered] = useState(false); + + // On mobile safari, the cursor will move from right to left with inputMode set to none during report transition + // To avoid that we should hide the cursor util the transition is finished + const [shouldTransparentCursor, setShouldTransparentCursor] = useState(!showSoftInputOnFocus && Browser.isMobileSafari()); + const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); const [prevScroll, setPrevScroll] = useState(); const isReportFlatListScrolling = useRef(false); @@ -256,6 +261,15 @@ function Composer( setIsRendered(true); }, []); + useEffect(() => { + if (!shouldTransparentCursor) { + return; + } + InteractionManager.runAfterInteractions(() => { + setShouldTransparentCursor(false); + }); + }, [shouldTransparentCursor]); + const clear = useCallback(() => { if (!textInput.current) { return; @@ -343,7 +357,7 @@ function Composer( placeholderTextColor={theme.placeholderText} ref={(el) => (textInput.current = el)} selection={selection} - style={[inputStyleMemo]} + style={[inputStyleMemo, shouldTransparentCursor ? {color: 'transparent'} : undefined]} markdownStyle={markdownStyle} value={value} defaultValue={defaultValue} From dc0c88f86d05f653cd6b67b58e53cf0dc486e5b1 Mon Sep 17 00:00:00 2001 From: Yauheni Pasiukevich Date: Wed, 20 Nov 2024 12:28:16 +0100 Subject: [PATCH 068/593] feat: Update corpay fields logic, integrate API --- src/CONST.ts | 1 + src/ONYXKEYS.ts | 4 + .../GetCorpayBankAccountFieldsParams.ts | 8 + src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/actions/BankAccounts.ts | 148 +----------------- .../NonUSD/BankInfo/BankInfo.tsx | 47 ++---- .../substeps/AccountHolderDetails.tsx | 134 ++++++++++++++++ .../BankInfo/substeps/BankAccountDetails.tsx | 12 +- .../NonUSD/BankInfo/substeps/Confirmation.tsx | 8 +- .../NonUSD/BankInfo/types.ts | 15 +- src/types/onyx/CorpayFields.ts | 42 +++++ src/types/onyx/index.ts | 2 + 13 files changed, 219 insertions(+), 205 deletions(-) create mode 100644 src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts create mode 100644 src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/AccountHolderDetails.tsx create mode 100644 src/types/onyx/CorpayFields.ts diff --git a/src/CONST.ts b/src/CONST.ts index c32248e6dcf3..276fefd81c98 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -629,6 +629,7 @@ const CONST = { HANG_TIGHT: 4, }, }, + BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX: 'accountHolder', }, INCORPORATION_TYPES: { LLC: 'LLC', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b4510a2faeed..1bf9a5244625 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -458,6 +458,9 @@ const ONYXKEYS = { /** The user's Concierge reportID */ CONCIERGE_REPORT_ID: 'conciergeReportID', + /* Corpay fieds to be used in the bank account creation setup */ + CORPAY_FIELDS: 'corpayFields', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -1029,6 +1032,7 @@ type OnyxValuesMapping = { [ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; [ONYXKEYS.CONCIERGE_REPORT_ID]: string; + [ONYXKEYS.CORPAY_FIELDS]: OnyxTypes.CorpayFields; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts b/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts new file mode 100644 index 000000000000..3e02b57f9e12 --- /dev/null +++ b/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts @@ -0,0 +1,8 @@ +type GetCorpayBankAccountFieldsParams = { + countryISO: string; + currency: string; + isWithdrawal: boolean; + isBusinessBankAccount: boolean; +}; + +export default GetCorpayBankAccountFieldsParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 681114fd3b08..feb73af644bc 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -29,6 +29,7 @@ export type {default as ExpandURLPreviewParams} from './ExpandURLPreviewParams'; export type {default as GetMissingOnyxMessagesParams} from './GetMissingOnyxMessagesParams'; export type {default as GetNewerActionsParams} from './GetNewerActionsParams'; export type {default as GetOlderActionsParams} from './GetOlderActionsParams'; +export type {default as GetCorpayBankAccountFieldsParams} from './GetCorpayBankAccountFieldsParams'; export type {default as GetPolicyCategoriesParams} from './GetPolicyCategories'; export type {default as GetReportPrivateNoteParams} from './GetReportPrivateNoteParams'; export type {default as GetRouteParams} from './GetRouteParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index bd8a58555617..55da017ff5e2 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -888,6 +888,7 @@ type WriteCommandParameters = { }; const READ_COMMANDS = { + GET_CORPAY_BANK_ACCOUNT_FIELDS: 'GetCorpayBankAccountFields', CONNECT_POLICY_TO_QUICKBOOKS_ONLINE: 'ConnectPolicyToQuickbooksOnline', CONNECT_POLICY_TO_XERO: 'ConnectPolicyToXero', SYNC_POLICY_TO_QUICKBOOKS_ONLINE: 'SyncPolicyToQuickbooksOnline', @@ -967,6 +968,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_PLAID_BANK_ACCOUNT_SELECTOR]: Parameters.OpenPlaidBankAccountSelectorParams; [READ_COMMANDS.GET_OLDER_ACTIONS]: Parameters.GetOlderActionsParams; [READ_COMMANDS.GET_NEWER_ACTIONS]: Parameters.GetNewerActionsParams; + [READ_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS]: Parameters.GetCorpayBankAccountFieldsParams; [READ_COMMANDS.EXPAND_URL_PREVIEW]: Parameters.ExpandURLPreviewParams; [READ_COMMANDS.GET_REPORT_PRIVATE_NOTE]: Parameters.GetReportPrivateNoteParams; [READ_COMMANDS.OPEN_ROOM_MEMBERS_PAGE]: Parameters.OpenRoomMembersPageParams; diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index bac1dba9ec71..4f149a026ddf 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -344,8 +344,6 @@ function validateBankAccount(bankAccountID: number, validateCode: string, policy } function getCorpayBankAccountFields(country: string, currency: string) { - // TODO - Use parameters when API is ready - // eslint-disable-next-line @typescript-eslint/no-unused-vars const parameters = { countryISO: country, currency, @@ -353,151 +351,7 @@ function getCorpayBankAccountFields(country: string, currency: string) { isBusinessBankAccount: true, }; - // return API.read(READ_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS, parameters); - return { - bankCountry: 'AU', - bankCurrency: 'AUD', - classification: 'Business', - destinationCountry: 'AU', - formFields: [ - { - errorMessage: 'Swift must be less than 12 characters', - id: 'swiftBicCode', - isRequired: false, - isRequiredInValueSet: true, - label: 'Swift Code', - regEx: '^.{0,12}$', - validationRules: [ - { - errorMessage: 'Swift must be less than 12 characters', - regEx: '^.{0,12}$', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], - }, - { - errorMessage: 'Beneficiary Bank Name must be less than 250 characters', - id: 'bankName', - isRequired: true, - isRequiredInValueSet: true, - label: 'Bank Name', - regEx: '^.{0,250}$', - validationRules: [ - { - errorMessage: 'Beneficiary Bank Name must be less than 250 characters', - regEx: '^.{0,250}$', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], - }, - { - errorMessage: 'City must be less than 100 characters', - id: 'bankCity', - isRequired: true, - isRequiredInValueSet: true, - label: 'Bank City', - regEx: '^.{0,100}$', - validationRules: [ - { - errorMessage: 'City must be less than 100 characters', - regEx: '^.{0,100}$', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], - }, - { - errorMessage: 'Bank Address Line 1 must be less than 1000 characters', - id: 'bankAddressLine1', - isRequired: true, - isRequiredInValueSet: true, - label: 'Bank Address', - regEx: '^.{0,1000}$', - validationRules: [ - { - errorMessage: 'Bank Address Line 1 must be less than 1000 characters', - regEx: '^.{0,1000}$', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], - }, - { - detailedRule: [ - { - isRequired: true, - value: [ - { - errorMessage: 'Beneficiary Account Number is invalid. Value should be 1 to 50 characters long.', - regEx: '^.{1,50}$', - ruleDescription: '1 to 50 characters', - }, - ], - }, - ], - errorMessage: 'Beneficiary Account Number is invalid. Value should be 1 to 50 characters long.', - id: 'accountNumber', - isRequired: true, - isRequiredInValueSet: true, - label: 'Account Number (iACH)', - regEx: '^.{1,50}$', - validationRules: [ - { - errorMessage: 'Beneficiary Account Number is invalid. Value should be 1 to 50 characters long.', - regEx: '^.{1,50}$', - ruleDescription: '1 to 50 characters', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], - }, - { - detailedRule: [ - { - isRequired: true, - value: [ - { - errorMessage: 'BSB Number is invalid. Value should be exactly 6 digits long.', - regEx: '^[0-9]{6}$', - ruleDescription: 'Exactly 6 digits', - }, - ], - }, - ], - errorMessage: 'BSB Number is invalid. Value should be exactly 6 digits long.', - id: 'routingCode', - isRequired: true, - isRequiredInValueSet: true, - label: 'BSB Number', - regEx: '^[0-9]{6}$', - validationRules: [ - { - errorMessage: 'BSB Number is invalid. Value should be exactly 6 digits long.', - regEx: '^[0-9]{6}$', - ruleDescription: 'Exactly 6 digits', - }, - { - errorMessage: 'The following characters are not allowed: <,>, "', - regEx: '^[^<>\\x22]*$', - }, - ], - }, - ], - paymentMethods: ['E'], - preferredMethod: 'E', - }; + return API.read(READ_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS, parameters); } function clearReimbursementAccount() { diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx index 7c5d853428c5..66cd84593a71 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx @@ -1,17 +1,16 @@ import type {ComponentType} from 'react'; -import React, {useCallback, useEffect, useState} from 'react'; +import React from 'react'; import {useOnyx} from 'react-native-onyx'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; -import * as BankAccounts from '@userActions/BankAccounts'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; +import AccountHolderDetails from './substeps/AccountHolderDetails'; import BankAccountDetails from './substeps/BankAccountDetails'; import Confirmation from './substeps/Confirmation'; import UploadStatement from './substeps/UploadStatement'; -import type {BankInfoSubStepProps, CorpayFormField} from './types'; +import type {BankInfoSubStepProps} from './types'; type BankInfoProps = { /** Handles back button press */ @@ -21,17 +20,11 @@ type BankInfoProps = { onSubmit: () => void; }; -const {COUNTRY} = INPUT_IDS.ADDITIONAL_DATA; - -const bodyContent: Array> = [BankAccountDetails, UploadStatement, Confirmation]; - function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) { const {translate} = useLocalize(); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); - const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const [corpayFields, setCorpayFields] = useState([]); - const country = reimbursementAccountDraft?.[COUNTRY] ?? ''; + const [corpayFields] = useOnyx(ONYXKEYS.CORPAY_FIELDS); const policyID = reimbursementAccount?.achData?.policyID ?? '-1'; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const currency = policy?.outputCurrency ?? ''; @@ -40,6 +33,9 @@ function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) { onSubmit(); }; + const bodyContent: Array> = + currency !== CONST.CURRENCY.AUD ? [BankAccountDetails, AccountHolderDetails, Confirmation] : [BankAccountDetails, AccountHolderDetails, UploadStatement, Confirmation]; + const { componentToRender: SubStep, isEditing, @@ -48,15 +44,8 @@ function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) { prevScreen, moveTo, goToTheLastStep, - resetScreenIndex, } = useSubStep({bodyContent, startFrom: 0, onFinished: submit}); - // Temporary solution to get the fields for the corpay bank account fields - useEffect(() => { - const response = BankAccounts.getCorpayBankAccountFields(country, currency); - setCorpayFields((response?.formFields as CorpayFormField[]) ?? []); - }, [country, currency]); - const handleBackButtonPress = () => { if (isEditing) { goToTheLastStep(); @@ -65,27 +54,11 @@ function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) { if (screenIndex === 0) { onBackButtonPress(); - } else if (currency === CONST.CURRENCY.AUD) { - prevScreen(); } else { - resetScreenIndex(); + prevScreen(); } }; - const handleNextScreen = useCallback(() => { - if (screenIndex === 2) { - nextScreen(); - return; - } - - if (currency !== CONST.CURRENCY.AUD) { - goToTheLastStep(); - return; - } - - nextScreen(); - }, [currency, goToTheLastStep, nextScreen, screenIndex]); - return ( ); diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/AccountHolderDetails.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/AccountHolderDetails.tsx new file mode 100644 index 000000000000..c0187b9ce7c7 --- /dev/null +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/AccountHolderDetails.tsx @@ -0,0 +1,134 @@ +import React, {useCallback, useMemo} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import PushRowWithModal from '@components/PushRowWithModal'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; +import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {BankInfoSubStepProps} from '@pages/ReimbursementAccount/NonUSD/BankInfo/types'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReimbursementAccountForm} from '@src/types/form/ReimbursementAccountForm'; +import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; + +const {ACCOUNT_HOLDER_COUNTRY} = INPUT_IDS.ADDITIONAL_DATA; + +function AccountHolderDetails({onNext, isEditing, corpayFields}: BankInfoSubStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const accountHolderDetailsFields = useMemo(() => { + return corpayFields?.filter((field) => field.id.includes(CONST.NON_USD_BANK_ACCOUNT.BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX)); + }, [corpayFields]); + const fieldIds = accountHolderDetailsFields?.map((field) => field.id); + + const subStepKeys = accountHolderDetailsFields?.reduce((acc, field) => { + acc[field.id as keyof ReimbursementAccountForm] = field.id as keyof ReimbursementAccountForm; + return acc; + }, {} as Record); + + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); + const defaultValues = useMemo(() => getSubstepValues(subStepKeys ?? {}, reimbursementAccountDraft, reimbursementAccount), [subStepKeys, reimbursementAccount, reimbursementAccountDraft]); + + const handleSubmit = useReimbursementAccountStepFormSubmit({ + fieldIds: fieldIds as Array>, + onNext, + shouldSaveDraft: isEditing, + }); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + + accountHolderDetailsFields?.forEach((field) => { + const fieldID = field.id as keyof FormOnyxValues; + + if (field.isRequired && !values[fieldID]) { + errors[fieldID] = translate('common.error.fieldRequired'); + } + + field.validationRules.forEach((rule) => { + if (!rule.regEx) { + return; + } + + if (new RegExp(rule.regEx).test(values[fieldID] ? String(values[fieldID]) : '')) { + return; + } + + errors[fieldID] = rule.errorMessage; + }); + }); + + return errors; + }, + [accountHolderDetailsFields, translate], + ); + + const inputs = useMemo(() => { + return accountHolderDetailsFields?.map((field) => { + if (field.id === ACCOUNT_HOLDER_COUNTRY) { + return ( + + + + ); + } + + return ( + + + + ); + }); + }, [accountHolderDetailsFields, styles.flex2, styles.mb6, styles.mhn5, defaultValues, isEditing, translate]); + + return ( + + + {translate('bankInfoStep.whatAreYour')} + {inputs} + + + ); +} + +AccountHolderDetails.displayName = 'AccountHolderDetails'; + +export default AccountHolderDetails; diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx index b3482a516c1f..d9bb9fe19671 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx @@ -16,13 +16,17 @@ function BankAccountDetails({onNext, isEditing, corpayFields}: BankInfoSubStepPr const {translate} = useLocalize(); const styles = useThemeStyles(); - const fieldIds = corpayFields.map((field) => field.id); + const bankAccountDetailsFields = useMemo(() => { + return corpayFields?.filter((field) => !field.id.includes(CONST.NON_USD_BANK_ACCOUNT.BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX)); + }, [corpayFields]); + + const fieldIds = bankAccountDetailsFields?.map((field) => field.id); const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; - corpayFields.forEach((field) => { + corpayFields?.forEach((field) => { const fieldID = field.id as keyof FormOnyxValues; if (field.isRequired && !values[fieldID]) { @@ -54,7 +58,7 @@ function BankAccountDetails({onNext, isEditing, corpayFields}: BankInfoSubStepPr }); const inputs = useMemo(() => { - return corpayFields.map((field) => { + return bankAccountDetailsFields?.map((field) => { return ( ); }); - }, [corpayFields, styles.flex2, styles.mb6, isEditing]); + }, [bankAccountDetailsFields, styles.flex2, styles.mb6, isEditing]); return ( { const keys: Record = {}; - corpayFields.forEach((field) => { - keys[field.id] = field.id; + corpayFields?.forEach((field) => { + keys[field.id] = field.id as keyof ReimbursementAccountForm; }); return keys; }, [corpayFields]); @@ -32,11 +32,11 @@ function Confirmation({onNext, onMove, corpayFields}: BankInfoSubStepProps) { const items = useMemo( () => ( <> - {corpayFields.map((field) => { + {corpayFields?.map((field) => { return ( { onMove(0); diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/types.ts b/src/pages/ReimbursementAccount/NonUSD/BankInfo/types.ts index 17943b29e3d3..4946b95ee496 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/types.ts +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/types.ts @@ -1,17 +1,6 @@ import type {SubStepProps} from '@hooks/useSubStep/types'; -import type {ReimbursementAccountForm} from '@src/types/form'; +import type {CorpayFormField} from '@src/types/onyx/CorpayFields'; -type CorpayFormField = { - id: keyof ReimbursementAccountForm; - isRequired: boolean; - errorMessage: string; - label: string; - regEx?: string; - validationRules: Array<{errorMessage: string; regEx: string}>; - defaultValue?: string; - detailedRule?: Array<{isRequired: boolean; value: Array<{errorMessage: string; regEx: string; ruleDescription: string}>}>; -}; - -type BankInfoSubStepProps = SubStepProps & {corpayFields: CorpayFormField[]}; +type BankInfoSubStepProps = SubStepProps & {corpayFields?: CorpayFormField[]}; export type {BankInfoSubStepProps, CorpayFormField}; diff --git a/src/types/onyx/CorpayFields.ts b/src/types/onyx/CorpayFields.ts new file mode 100644 index 000000000000..da4c93b66834 --- /dev/null +++ b/src/types/onyx/CorpayFields.ts @@ -0,0 +1,42 @@ +/** + * Represents a form field with validation rules. + */ +type CorpayFormField = { + /** Error message for the form field */ + errorMessage: string; + /** Unique identifier for the form field */ + id: string; + /** Indicates if the field is required */ + isRequired: boolean; + /** Indicates if the field is required in the value set */ + isRequiredInValueSet: boolean; + /** Label for the form field */ + label: string; + /** Regular expression for the form field */ + regEx: string; + /** Validation rules for the form field */ + validationRules: Array<{ + /** Error message for the validation rule */ + errorMessage: string; + /** Regular expression for the validation rule */ + regEx: string; + }>; +}; + +/** CorpayFormFields */ +type CorpayFormFields = { + /** Country of the bank */ + bankCountry: string; + /** Currency of the bank */ + bankCurrency: string; + /** Classification of the bank */ + classification: string; + /** Destination country of the bank */ + destinationCountry: string; + /** Form fields for the Corpay form */ + formFields: CorpayFormField[]; +}; + +export default CorpayFormFields; + +export type {CorpayFormField}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index cec5243990a6..21fed723a07e 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -16,6 +16,7 @@ import type CardFeeds from './CardFeeds'; import type {AddNewCompanyCardFeed, CompanyCardFeed} from './CardFeeds'; import type CardOnWaitlist from './CardOnWaitlist'; import type {CapturedLogs, Log} from './Console'; +import type CorpayFields from './CorpayFields'; import type Credentials from './Credentials'; import type Currency from './Currency'; import type {CurrencyList} from './Currency'; @@ -124,6 +125,7 @@ export type { CardList, CardOnWaitlist, Credentials, + CorpayFields, Currency, CurrencyList, CustomStatusDraft, From 57a8eac193a21a56b644126b2ce476f685fe9c6a Mon Sep 17 00:00:00 2001 From: Huu Le <20178761+huult@users.noreply.github.com> Date: Wed, 20 Nov 2024 20:42:41 +0700 Subject: [PATCH 069/593] fix clear cached data after account switch --- src/libs/actions/QueuedOnyxUpdates.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts index bc19ff12aea1..b9d2a20bdd2c 100644 --- a/src/libs/actions/QueuedOnyxUpdates.ts +++ b/src/libs/actions/QueuedOnyxUpdates.ts @@ -1,9 +1,18 @@ -import type {OnyxUpdate} from 'react-native-onyx'; +import type {OnyxKey, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; // In this file we manage a queue of Onyx updates while the SequentialQueue is processing. There are functions to get the updates and clear the queue after saving the updates in Onyx. let queuedOnyxUpdates: OnyxUpdate[] = []; +let currentAccountID: number | undefined; + +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (session) => { + currentAccountID = session?.accountID; + }, +}); /** * @param updates Onyx updates to queue for later @@ -14,6 +23,20 @@ function queueOnyxUpdates(updates: OnyxUpdate[]): Promise { } function flushQueue(): Promise { + if (!currentAccountID) { + const preservedKeys: OnyxKey[] = [ + ONYXKEYS.NVP_TRY_FOCUS_MODE, + ONYXKEYS.PREFERRED_THEME, + ONYXKEYS.NVP_PREFERRED_LOCALE, + ONYXKEYS.SESSION, + ONYXKEYS.IS_LOADING_APP, + ONYXKEYS.CREDENTIALS, + ONYXKEYS.IS_SIDEBAR_LOADED, + ]; + + queuedOnyxUpdates = queuedOnyxUpdates.filter((update) => preservedKeys.includes(update.key as OnyxKey)); + } + return Onyx.update(queuedOnyxUpdates).then(() => { queuedOnyxUpdates = []; }); From d4b216546a4fa66cf5b8056412dadadcc14ab179 Mon Sep 17 00:00:00 2001 From: Yauheni Pasiukevich Date: Wed, 20 Nov 2024 15:08:20 +0100 Subject: [PATCH 070/593] fix: variable value --- .../NonUSD/BankInfo/substeps/AccountHolderDetails.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/AccountHolderDetails.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/AccountHolderDetails.tsx index c0187b9ce7c7..acb69d3312bb 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/AccountHolderDetails.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/AccountHolderDetails.tsx @@ -17,7 +17,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ReimbursementAccountForm} from '@src/types/form/ReimbursementAccountForm'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -const {ACCOUNT_HOLDER_COUNTRY} = INPUT_IDS.ADDITIONAL_DATA; +const {ACCOUNT_HOLDER_COUNTRY} = INPUT_IDS.ADDITIONAL_DATA.CORPAY; function AccountHolderDetails({onNext, isEditing, corpayFields}: BankInfoSubStepProps) { const {translate} = useLocalize(); From cd04c395d16a28ed90cdf56ad0e978480333b99a Mon Sep 17 00:00:00 2001 From: Huu Le <20178761+huult@users.noreply.github.com> Date: Thu, 21 Nov 2024 14:22:56 +0700 Subject: [PATCH 071/593] Skip executing filter queue update for Onyx in test environment --- src/CONFIG.ts | 1 + src/libs/actions/QueuedOnyxUpdates.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/CONFIG.ts b/src/CONFIG.ts index e5e9a9d1540a..72f98c0ee106 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -104,4 +104,5 @@ export default { // to read more about StrictMode see: contributingGuides/STRICT_MODE.md USE_REACT_STRICT_MODE_IN_DEV: false, ELECTRON_DISABLE_SECURITY_WARNINGS: 'true', + IS_TEST_ENV: process.env.NODE_ENV === 'test', } as const; diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts index b9d2a20bdd2c..74c5cb6b12f2 100644 --- a/src/libs/actions/QueuedOnyxUpdates.ts +++ b/src/libs/actions/QueuedOnyxUpdates.ts @@ -1,5 +1,6 @@ import type {OnyxKey, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import CONFIG from '@src/CONFIG'; import ONYXKEYS from '@src/ONYXKEYS'; // In this file we manage a queue of Onyx updates while the SequentialQueue is processing. There are functions to get the updates and clear the queue after saving the updates in Onyx. @@ -23,7 +24,7 @@ function queueOnyxUpdates(updates: OnyxUpdate[]): Promise { } function flushQueue(): Promise { - if (!currentAccountID) { + if (!currentAccountID && !CONFIG.IS_TEST_ENV) { const preservedKeys: OnyxKey[] = [ ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, From f375a4f2b2e6491a4884c769ffa75e418379cdc9 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 22 Nov 2024 13:53:30 +0100 Subject: [PATCH 072/593] fix: web project crashes --- .../executeOnUIRuntimeSync/index.native.ts | 3 +++ .../useWorkletStateMachine/executeOnUIRuntimeSync/index.ts | 3 +++ .../index.ts} | 3 ++- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.native.ts create mode 100644 src/hooks/useWorkletStateMachine/executeOnUIRuntimeSync/index.ts rename src/hooks/{useWorkletStateMachine.ts => useWorkletStateMachine/index.ts} (97%) 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.ts b/src/hooks/useWorkletStateMachine/index.ts similarity index 97% rename from src/hooks/useWorkletStateMachine.ts rename to src/hooks/useWorkletStateMachine/index.ts index 105814c094eb..dcedf002fc15 100644 --- a/src/hooks/useWorkletStateMachine.ts +++ b/src/hooks/useWorkletStateMachine/index.ts @@ -1,6 +1,7 @@ import {useCallback} from 'react'; -import {executeOnUIRuntimeSync, runOnJS, runOnUI, useSharedValue} from 'react-native-reanimated'; +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; From 4e7d9316e9a2626514043c5f373b54082db8a863 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 22 Nov 2024 14:03:50 +0100 Subject: [PATCH 073/593] fix: prettier --- src/pages/home/report/ReportActionItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 1e0f4140a6c8..1f1d5e0fdf4f 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -370,7 +370,7 @@ function ReportActionItem({ } handleShowContextMenu(() => { - setIsContextMenuActive(true); + setIsContextMenuActive(true); const selection = SelectionScraper.getCurrentSelection(); ReportActionContextMenu.showContextMenu( CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, From b48a247a6f20a0eec11c4e73bd19af604c9ec867 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 22 Nov 2024 14:58:05 +0100 Subject: [PATCH 074/593] fix: long press of a video attachment does not push up the video message --- .../VideoPlayerPreview/VideoPlayerThumbnail.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 > From 6019f6465f5ed94c18fbe2205784ff2b568698f8 Mon Sep 17 00:00:00 2001 From: christianwen Date: Mon, 25 Nov 2024 15:04:56 +0700 Subject: [PATCH 075/593] transparent caret safari --- src/components/Composer/implementation/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index aa7ab0f87898..3a78d9fda0a5 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -359,7 +359,7 @@ function Composer( placeholderTextColor={theme.placeholderText} ref={(el) => (textInput.current = el)} selection={selection} - style={[inputStyleMemo, shouldTransparentCursor ? {color: 'transparent'} : undefined]} + style={[inputStyleMemo, shouldTransparentCursor ? {caretColor: 'transparent'} : undefined]} markdownStyle={markdownStyle} value={value} defaultValue={defaultValue} From 9fa35aba365bb10caa0048a7087f34c3532d50c7 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Mon, 25 Nov 2024 22:36:08 +0700 Subject: [PATCH 076/593] sort all transactons correctly --- src/libs/PolicyUtils.ts | 4 +-- src/libs/ReportUtils.ts | 2 +- src/libs/TransactionUtils/index.ts | 40 +++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index dc1c07388293..e47e19f3d330 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -37,7 +37,7 @@ import * as Localize from './Localize'; import Navigation from './Navigation/Navigation'; import * as NetworkStore from './Network/NetworkStore'; import {getAccountIDsByLogins, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils'; -import {getAllReportTransactions, getCategory, getTag} from './TransactionUtils'; +import {getAllSortedTransactions, getCategory, getTag} from './TransactionUtils'; type MemberEmailsToAccountIDs = Record; @@ -538,7 +538,7 @@ function getSubmitToAccountID(policy: OnyxEntry, expenseReport: OnyxEntr let categoryAppover; let tagApprover; - const allTransactions = getAllReportTransactions(expenseReport?.reportID).sort((transA, transB) => (transA.created < transB.created ? -1 : 1)); + const allTransactions = getAllSortedTransactions(expenseReport?.reportID ?? '-1'); // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. // Category approvers are prioritized, then tag approvers. diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ff9e85c24e82..e598289a373c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -8387,7 +8387,7 @@ function isExported(reportActions: OnyxEntry) { function getRuleApprovers(policy: OnyxEntry, expenseReport: OnyxEntry) { const categoryAppovers: string[] = []; const tagApprovers: string[] = []; - const allReportTransactions = TransactionUtils.getAllReportTransactions(expenseReport?.reportID).sort((transA, transB) => (transA.created < transB.created ? -1 : 1)); + const allReportTransactions = TransactionUtils.getAllSortedTransactions(expenseReport?.reportID ?? '-1'); // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. // Category approvers are prioritized, then tag approvers. diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 1fc7cad2f456..44058cd416d0 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -25,7 +25,20 @@ import type {IOURequestType} from '@userActions/IOU'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Beta, OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type { + Beta, + OnyxInputOrEntry, + Policy, + RecentWaypoint, + Report, + ReportAction, + ReviewDuplicates, + TaxRate, + TaxRates, + Transaction, + TransactionViolation, + TransactionViolations, +} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {SearchPolicy, SearchReport} from '@src/types/onyx/SearchResults'; import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; @@ -1218,6 +1231,30 @@ function buildTransactionsMergeParams(reviewDuplicates: OnyxEntry> { + // We need sort all transactions by sorting the parent report actions because `created` of the transaction only has format `YYYY-MM-DD` which can cause the wrong sorting + const allCreatedIOUActions = Object.values(ReportActionsUtils.getAllReportActions(iouReportID)) + ?.filter((reportAction): reportAction is ReportAction => { + if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) { + return false; + } + const message = ReportActionsUtils.getOriginalMessage(reportAction); + if (!message?.IOUTransactionID) { + return false; + } + return true; + }) + .sort((actionA, actionB) => (actionA.created < actionB.created ? -1 : 1)); + + return allCreatedIOUActions.map((iouAction) => { + const transactionID = ReportActionsUtils.getOriginalMessage(iouAction)?.IOUTransactionID ?? '-1'; + return getTransaction(transactionID); + }); +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -1301,6 +1338,7 @@ export { getCardName, hasReceiptSource, shouldShowAttendees, + getAllSortedTransactions, }; export type {TransactionChanges}; From 54e779f6d6722d1f7579bf1a8161280c6480fde2 Mon Sep 17 00:00:00 2001 From: Yauheni Pasiukevich Date: Tue, 26 Nov 2024 10:12:22 +0100 Subject: [PATCH 077/593] feat: Add Corpay bank account creation parameters and update related components --- .../BankAccountCreateCorpayParams.ts | 8 ++++++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 ++ src/libs/actions/BankAccounts.ts | 14 +++++++++- .../NonUSD/BankInfo/BankInfo.tsx | 13 ++++++++- .../BankInfo/substeps/BankAccountDetails.tsx | 2 +- .../NonUSD/BankInfo/substeps/Confirmation.tsx | 10 +++++-- .../NonUSD/BankInfo/types.ts | 2 +- .../NonUSD/BusinessInfo/substeps/Address.tsx | 2 +- .../NonUSD/Country/substeps/Confirmation.tsx | 27 ++++++++++--------- src/types/form/ReimbursementAccountForm.ts | 12 +++++++-- src/types/onyx/CorpayFields.ts | 2 ++ src/types/onyx/ReimbursementAccount.ts | 4 ++- 13 files changed, 77 insertions(+), 22 deletions(-) create mode 100644 src/libs/API/parameters/BankAccountCreateCorpayParams.ts diff --git a/src/libs/API/parameters/BankAccountCreateCorpayParams.ts b/src/libs/API/parameters/BankAccountCreateCorpayParams.ts new file mode 100644 index 000000000000..3c617d326009 --- /dev/null +++ b/src/libs/API/parameters/BankAccountCreateCorpayParams.ts @@ -0,0 +1,8 @@ +type BankAccountCreateCorpayParams = { + type: number; + isSavings: boolean; + isWithdrawal: boolean; + inputs: string; +}; + +export default BankAccountCreateCorpayParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index feb73af644bc..1f3a3500a229 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -8,6 +8,7 @@ export type {default as RestartBankAccountSetupParams} from './RestartBankAccoun export type {default as AddSchoolPrincipalParams} from './AddSchoolPrincipalParams'; export type {default as AuthenticatePusherParams} from './AuthenticatePusherParams'; export type {default as BankAccountHandlePlaidErrorParams} from './BankAccountHandlePlaidErrorParams'; +export type {default as BankAccountCreateCorpayParams} from './BankAccountCreateCorpayParams'; export type {default as BeginAppleSignInParams} from './BeginAppleSignInParams'; export type {default as BeginGoogleSignInParams} from './BeginGoogleSignInParams'; export type {default as BeginSignInParams} from './BeginSignInParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 55da017ff5e2..3b4af17b492f 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -437,6 +437,7 @@ const WRITE_COMMANDS = { SELF_TOUR_VIEWED: 'SelfTourViewed', UPDATE_INVOICE_COMPANY_NAME: 'UpdateInvoiceCompanyName', UPDATE_INVOICE_COMPANY_WEBSITE: 'UpdateInvoiceCompanyWebsite', + BANK_ACCOUNT_CREATE_CORPAY: 'BankAccount_CreateCorpay', } as const; type WriteCommand = ValueOf; @@ -765,6 +766,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.APPROVE_MONEY_REQUEST_ON_SEARCH]: Parameters.ApproveMoneyRequestOnSearchParams; [WRITE_COMMANDS.PAY_MONEY_REQUEST_ON_SEARCH]: Parameters.PayMoneyRequestOnSearchParams; [WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.UnholdMoneyRequestOnSearchParams; + [WRITE_COMMANDS.BANK_ACCOUNT_CREATE_CORPAY]: Parameters.BankAccountCreateCorpayParams; [WRITE_COMMANDS.REQUEST_REFUND]: null; [WRITE_COMMANDS.CONNECT_POLICY_TO_SAGE_INTACCT]: Parameters.ConnectPolicyToSageIntacctParams; diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 4f149a026ddf..067511e03fc2 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -20,7 +20,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {PersonalBankAccountForm} from '@src/types/form'; -import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, RequestorStepProps} from '@src/types/form/ReimbursementAccountForm'; +import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, ReimbursementAccountForm, RequestorStepProps} from '@src/types/form/ReimbursementAccountForm'; import type PlaidBankAccount from '@src/types/onyx/PlaidBankAccount'; import type {BankAccountStep, ReimbursementAccountStep, ReimbursementAccountSubStep} from '@src/types/onyx/ReimbursementAccount'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -354,6 +354,17 @@ function getCorpayBankAccountFields(country: string, currency: string) { return API.read(READ_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS, parameters); } +function createCorpayBankAccount(fields: ReimbursementAccountForm) { + const parameters = { + type: 1, + isSavings: false, + isWithdrawal: true, + inputs: JSON.stringify(fields), + }; + + return API.write(WRITE_COMMANDS.BANK_ACCOUNT_CREATE_CORPAY, parameters); +} + function clearReimbursementAccount() { Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, null); } @@ -566,6 +577,7 @@ export { openPlaidView, connectBankAccountManually, connectBankAccountWithPlaid, + createCorpayBankAccount, deletePaymentBankAccount, handlePlaidError, setPersonalBankAccountContinueKYCOnSuccess, diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx index 66cd84593a71..cf83a2365669 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx @@ -1,17 +1,21 @@ import type {ComponentType} from 'react'; -import React from 'react'; +import React, {useEffect} from 'react'; import {useOnyx} from 'react-native-onyx'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; +import * as BankAccounts from '@userActions/BankAccounts'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import AccountHolderDetails from './substeps/AccountHolderDetails'; import BankAccountDetails from './substeps/BankAccountDetails'; import Confirmation from './substeps/Confirmation'; import UploadStatement from './substeps/UploadStatement'; import type {BankInfoSubStepProps} from './types'; +const {DESTINATION_COUNTRY} = INPUT_IDS.ADDITIONAL_DATA; + type BankInfoProps = { /** Handles back button press */ onBackButtonPress: () => void; @@ -24,15 +28,21 @@ function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) { const {translate} = useLocalize(); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); const [corpayFields] = useOnyx(ONYXKEYS.CORPAY_FIELDS); const policyID = reimbursementAccount?.achData?.policyID ?? '-1'; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const currency = policy?.outputCurrency ?? ''; + const country = reimbursementAccountDraft?.[DESTINATION_COUNTRY] ?? ''; const submit = () => { onSubmit(); }; + useEffect(() => { + BankAccounts.getCorpayBankAccountFields(country, currency); + }, [country, currency]); + const bodyContent: Array> = currency !== CONST.CURRENCY.AUD ? [BankAccountDetails, AccountHolderDetails, Confirmation] : [BankAccountDetails, AccountHolderDetails, UploadStatement, Confirmation]; @@ -72,6 +82,7 @@ function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) { onNext={nextScreen} onMove={moveTo} corpayFields={corpayFields?.formFields} + preferredMethod={corpayFields?.preferredMethod} /> ); diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx index d9bb9fe19671..5e1356430440 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx @@ -34,7 +34,7 @@ function BankAccountDetails({onNext, isEditing, corpayFields}: BankInfoSubStepPr } field.validationRules.forEach((rule) => { - if (rule.regEx) { + if (!rule.regEx) { return; } diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx index c10c98b77418..2d22b52d28b6 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx @@ -10,11 +10,12 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {BankInfoSubStepProps} from '@pages/ReimbursementAccount/NonUSD/BankInfo/types'; import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import * as BankAccounts from '@userActions/BankAccounts'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReimbursementAccountForm} from '@src/types/form/ReimbursementAccountForm'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; -function Confirmation({onNext, onMove, corpayFields}: BankInfoSubStepProps) { +function Confirmation({onNext, onMove, corpayFields, preferredMethod}: BankInfoSubStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -58,6 +59,11 @@ function Confirmation({onNext, onMove, corpayFields}: BankInfoSubStepProps) { [corpayFields, onMove, reimbursementAccountDraft, translate, values], ); + const handleNext = () => { + BankAccounts.createCorpayBankAccount({...reimbursementAccountDraft, preferredMethod} as ReimbursementAccountForm); + onNext(); + }; + return ( {({safeAreaPaddingBottomStyle}) => ( @@ -72,7 +78,7 @@ function Confirmation({onNext, onMove, corpayFields}: BankInfoSubStepProps) {