diff --git a/src/App.tsx b/src/App.tsx index a3a9f7a3f3b6..006028271c80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import {WindowDimensionsProvider} from './components/withWindowDimensions'; import Expensify from './Expensify'; import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; +import {SuggestionsContextProvider} from './pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; import type {Route} from './ROUTES'; @@ -79,6 +80,7 @@ function App({url}: AppProps) { ActiveElementRoleProvider, ActiveWorkspaceContextProvider, PlaybackContextProvider, + SuggestionsContextProvider, FullScreenContextProvider, VolumeContextProvider, VideoPopoverMenuContextProvider, diff --git a/src/CONST.ts b/src/CONST.ts index f0139d82e614..c2299f242b6b 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -72,6 +72,7 @@ type OnboardingPurposeType = ValueOf; const CONST = { MERGED_ACCOUNT_PREFIX: 'MERGED_', DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL], + DEFAULT_COMPOSER_PORTAL_HOST_NAME: 'suggestions_0', // Note: Group and Self-DM excluded as these are not tied to a Workspace WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT], @@ -1171,6 +1172,7 @@ const CONST = { EMOJI_PICKER_HEADER_HEIGHT: 32, RECIPIENT_LOCAL_TIME_HEIGHT: 25, AUTO_COMPLETE_SUGGESTER: { + EDIT_SUGGESTER_PADDING: 8, SUGGESTER_PADDING: 6, SUGGESTER_INNER_PADDING: 8, SUGGESTION_ROW_HEIGHT: 40, diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index ccd0f21626a0..a11fb2a01cc8 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -10,6 +10,7 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {useSuggestionsContext} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import viewForwardedRef from '@src/types/utils/viewForwardedRef'; @@ -39,6 +40,7 @@ function BaseAutoCompleteSuggestions( suggestions, isSuggestionPickerLarge, keyExtractor, + shouldBeDisplayedBelowParentContainer = false, }: AutoCompleteSuggestionsProps, ref: ForwardedRef, ) { @@ -47,6 +49,7 @@ function BaseAutoCompleteSuggestions( const StyleUtils = useStyleUtils(); const rowHeight = useSharedValue(0); const scrollRef = useRef>(null); + const {activeID} = useSuggestionsContext(); /** * Render a suggestion menu item component. */ @@ -68,7 +71,7 @@ function BaseAutoCompleteSuggestions( ); const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length; - const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value)); + const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value, shouldBeDisplayedBelowParentContainer, Boolean(activeID))); const estimatedListSize = useMemo( () => ({ height: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length, diff --git a/src/components/AutoCompleteSuggestions/index.native.tsx b/src/components/AutoCompleteSuggestions/index.native.tsx index fbfa7d953581..863e806b143e 100644 --- a/src/components/AutoCompleteSuggestions/index.native.tsx +++ b/src/components/AutoCompleteSuggestions/index.native.tsx @@ -1,11 +1,14 @@ import {Portal} from '@gorhom/portal'; import React from 'react'; +import {useSuggestionsContext} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; +import CONST from '@src/CONST'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import type {AutoCompleteSuggestionsProps} from './types'; function AutoCompleteSuggestions({measureParentContainer, ...props}: AutoCompleteSuggestionsProps) { + const {activeID} = useSuggestionsContext(); return ( - + {/* eslint-disable-next-line react/jsx-props-no-spreading */} {...props} /> diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index baca4011a177..66ea0de6f9f3 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -4,6 +4,7 @@ import {View} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import {measureHeightOfSuggestionsContainer} from '@libs/SuggestionUtils'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import type {AutoCompleteSuggestionsProps} from './types'; @@ -18,11 +19,13 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} const StyleUtils = useStyleUtils(); const containerRef = React.useRef(null); const {windowHeight, windowWidth} = useWindowDimensions(); + const suggestionsContainerHeight = measureHeightOfSuggestionsContainer(props.suggestions.length, props.isSuggestionPickerLarge); const [{width, left, bottom}, setContainerState] = React.useState({ width: 0, left: 0, bottom: 0, }); + const [shouldShowBelowContainer, setShouldShowBelowContainer] = React.useState(false); React.useEffect(() => { const container = containerRef.current; if (!container) { @@ -41,13 +44,19 @@ function AutoCompleteSuggestions({measureParentContainer = () => {} if (!measureParentContainer) { return; } - measureParentContainer((x, y, w) => setContainerState({left: x, bottom: windowHeight - y, width: w})); - }, [measureParentContainer, windowHeight, windowWidth]); + + measureParentContainer((x, y, w, h) => { + const currentBottom = y < suggestionsContainerHeight ? windowHeight - y - suggestionsContainerHeight - h : windowHeight - y; + setShouldShowBelowContainer(y < suggestionsContainerHeight); + setContainerState({left: x, bottom: currentBottom, width: w}); + }); + }, [measureParentContainer, windowHeight, windowWidth, suggestionsContainerHeight]); const componentToRender = ( // eslint-disable-next-line react/jsx-props-no-spreading {...props} + shouldBeDisplayedBelowParentContainer={shouldShowBelowContainer} ref={containerRef} /> ); diff --git a/src/components/AutoCompleteSuggestions/types.ts b/src/components/AutoCompleteSuggestions/types.ts index 61d614dcf2e4..d9824db1988d 100644 --- a/src/components/AutoCompleteSuggestions/types.ts +++ b/src/components/AutoCompleteSuggestions/types.ts @@ -1,6 +1,6 @@ import type {ReactElement} from 'react'; -type MeasureParentContainerCallback = (x: number, y: number, width: number) => void; +type MeasureParentContainerCallback = (x: number, y: number, width: number, height: number) => void; type RenderSuggestionMenuItemProps = { item: TSuggestion; @@ -33,6 +33,9 @@ type AutoCompleteSuggestionsProps = { /** Meaures the parent container's position and dimensions. */ measureParentContainer?: (callback: MeasureParentContainerCallback) => void; + + /** Whether suggestion should be displayed below the parent container or not */ + shouldBeDisplayedBelowParentContainer?: boolean; }; export type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps}; diff --git a/src/components/EmojiSuggestions.tsx b/src/components/EmojiSuggestions.tsx index 1c0306741048..c95288f43164 100644 --- a/src/components/EmojiSuggestions.tsx +++ b/src/components/EmojiSuggestions.tsx @@ -9,7 +9,7 @@ import getStyledTextArray from '@libs/GetStyledTextArray'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; import Text from './Text'; -type MeasureParentContainerCallback = (x: number, y: number, width: number) => void; +type MeasureParentContainerCallback = (x: number, y: number, width: number, height: number) => void; type EmojiSuggestionsProps = { /** The index of the highlighted emoji */ diff --git a/src/libs/SuggestionUtils.ts b/src/libs/SuggestionUtils.ts index 96379ce49ef3..a38ba83a04bf 100644 --- a/src/libs/SuggestionUtils.ts +++ b/src/libs/SuggestionUtils.ts @@ -20,4 +20,25 @@ function hasEnoughSpaceForLargeSuggestionMenu(listHeight: number, composerHeight return availableHeight > menuHeight; } -export {trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu}; +const measureHeightOfSuggestionsContainer = (numRows: number, isSuggestionsPickerLarge: boolean): number => { + // Autocomplete suggestions has inner padding 8px and border-width 1px + const borderAndPadding = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING + 2; + let suggestionsHeight = 0; + + if (isSuggestionsPickerLarge) { + if (numRows > CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER) { + // On large screens, if there are more than 5 suggestions, we display a scrollable window with a height of 5 items, indicating that there are more items available + suggestionsHeight = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } else { + suggestionsHeight = numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + } else if (numRows > 2) { + // On small screens, we display a scrollable window with a height of 2.5 items, indicating that there are more items available beyond what is currently visible + suggestionsHeight = CONST.AUTO_COMPLETE_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } else { + suggestionsHeight = numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + return suggestionsHeight + borderAndPadding; +}; + +export {trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu, measureHeightOfSuggestionsContainer}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx new file mode 100644 index 000000000000..c7510710828d --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit.tsx @@ -0,0 +1,98 @@ +import type {Dispatch, ForwardedRef, RefObject, SetStateAction} from 'react'; +import React, {useState} from 'react'; +import type {MeasureInWindowOnSuccessCallback, TextInput} from 'react-native'; +import Composer from '@components/Composer'; +import type {ComposerProps} from '@components/Composer/types'; +import type {SuggestionsRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose'; +import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions'; + +type Selection = { + start: number; + end: number; +}; + +type ComposerWithSuggestionsEditProps = ComposerProps & { + setValue: Dispatch>; + setSelection: Dispatch>; + resetKeyboardInput: () => void; + isComposerFocused: boolean; + suggestionsRef: RefObject; + updateDraft: (newValue: string) => void; + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + value: string; + selection: Selection; + isGroupPolicyReport: boolean; +}; + +function ComposerWithSuggestionsEdit( + { + value, + maxLines = -1, + onKeyPress = () => {}, + style, + onSelectionChange = () => {}, + selection = { + start: 0, + end: 0, + }, + onBlur = () => {}, + onFocus = () => {}, + onChangeText = () => {}, + setValue = () => {}, + setSelection = () => {}, + resetKeyboardInput = () => {}, + isComposerFocused, + suggestionsRef, + updateDraft, + measureParentContainer, + id = undefined, + isGroupPolicyReport, + }: ComposerWithSuggestionsEditProps, + ref: ForwardedRef, +) { + const [composerHeight, setComposerHeight] = useState(0); + + return ( + <> + { + const composerLayoutHeight = e.nativeEvent.layout.height; + if (composerHeight === composerLayoutHeight) { + return; + } + setComposerHeight(composerLayoutHeight); + }} + /> + + + + ); +} + +export default React.forwardRef(ComposerWithSuggestionsEdit); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx new file mode 100644 index 000000000000..ceecb56af450 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext.tsx @@ -0,0 +1,56 @@ +import type {MutableRefObject, ReactNode} from 'react'; +import React, {createContext, useCallback, useContext, useMemo, useRef, useState} from 'react'; +import type {SuggestionsRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose'; + +type SuggestionsContextProviderProps = { + children?: ReactNode; +}; + +type SuggestionsContextProps = { + activeID: string | null; + currentActiveSuggestionsRef: MutableRefObject; + updateCurrentActiveSuggestionsRef: (ref: SuggestionsRef | null, id: string) => void; + clearActiveSuggestionsRef: () => void; + isActiveSuggestions: (id: string) => boolean; +}; + +const SuggestionsContext = createContext({ + activeID: null, + currentActiveSuggestionsRef: {current: null}, + updateCurrentActiveSuggestionsRef: () => {}, + clearActiveSuggestionsRef: () => {}, + isActiveSuggestions: () => false, +}); + +function SuggestionsContextProvider({children}: SuggestionsContextProviderProps) { + const currentActiveSuggestionsRef = useRef(null); + const [activeID, setActiveID] = useState(null); + + const updateCurrentActiveSuggestionsRef = useCallback((ref: SuggestionsRef | null, id: string) => { + currentActiveSuggestionsRef.current = ref; + setActiveID(id); + }, []); + + const clearActiveSuggestionsRef = useCallback(() => { + currentActiveSuggestionsRef.current = null; + setActiveID(null); + }, []); + + const isActiveSuggestions = useCallback((id: string) => id === activeID, [activeID]); + + const contextValue = useMemo( + () => ({activeID, currentActiveSuggestionsRef, updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef, isActiveSuggestions}), + [activeID, currentActiveSuggestionsRef, updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef, isActiveSuggestions], + ); + + return {children}; +} + +function useSuggestionsContext() { + const context = useContext(SuggestionsContext); + return context; +} + +SuggestionsContextProvider.displayName = 'SuggestionsContextProvider'; + +export {SuggestionsContextProvider, useSuggestionsContext}; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index b9b9025bb02b..838e2466c6f3 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -61,6 +61,7 @@ type SuggestionsRef = { updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; getSuggestions: () => Mention[] | Emoji[]; + updateShouldShowSuggestionMenuAfterScrolling: () => void; }; type ReportActionComposeOnyxProps = { @@ -378,7 +379,7 @@ function ReportActionCompose({ {shouldShowReportRecipientLocalTime && hasReportRecipient && } - + { + setSuggestionValues((prevState) => ({...prevState, shouldShowSuggestionMenu: !!prevState.suggestedEmojis.length})); + }, []); + /** * Listens for keyboard shortcuts and applies the action */ @@ -215,8 +219,17 @@ function SuggestionEmoji( setShouldBlockSuggestionCalc, updateShouldShowSuggestionMenuToFalse, getSuggestions, + updateShouldShowSuggestionMenuAfterScrolling, }), - [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions], + [ + onSelectionChange, + resetSuggestions, + setShouldBlockSuggestionCalc, + triggerHotkeyActions, + updateShouldShowSuggestionMenuToFalse, + getSuggestions, + updateShouldShowSuggestionMenuAfterScrolling, + ], ); if (!isEmojiSuggestionsMenuVisible) { diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx index dbd550e1cd7c..37032a2550fe 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx @@ -360,6 +360,10 @@ function SuggestionMention( }); }, []); + const updateShouldShowSuggestionMenuAfterScrolling = useCallback(() => { + setSuggestionValues((prevState) => ({...prevState, shouldShowSuggestionMenu: !!prevState.suggestedMentions.length})); + }, []); + const setShouldBlockSuggestionCalc = useCallback( (shouldBlockSuggestionCalc: boolean) => { shouldBlockCalc.current = shouldBlockSuggestionCalc; @@ -377,8 +381,9 @@ function SuggestionMention( setShouldBlockSuggestionCalc, updateShouldShowSuggestionMenuToFalse, getSuggestions, + updateShouldShowSuggestionMenuAfterScrolling, }), - [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions], + [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions, updateShouldShowSuggestionMenuAfterScrolling], ); if (!isMentionSuggestionsMenuVisible) { diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.tsx b/src/pages/home/report/ReportActionCompose/Suggestions.tsx index 8ebd52f62428..288a8b1a6d81 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/Suggestions.tsx @@ -127,6 +127,11 @@ function Suggestions( suggestionMentionRef.current?.updateShouldShowSuggestionMenuToFalse(); }, []); + const updateShouldShowSuggestionMenuAfterScrolling = useCallback(() => { + suggestionEmojiRef.current?.updateShouldShowSuggestionMenuAfterScrolling(); + suggestionMentionRef.current?.updateShouldShowSuggestionMenuAfterScrolling(); + }, []); + const setShouldBlockSuggestionCalc = useCallback((shouldBlock: boolean) => { suggestionEmojiRef.current?.setShouldBlockSuggestionCalc(shouldBlock); suggestionMentionRef.current?.setShouldBlockSuggestionCalc(shouldBlock); @@ -141,8 +146,17 @@ function Suggestions( updateShouldShowSuggestionMenuToFalse, setShouldBlockSuggestionCalc, getSuggestions, + updateShouldShowSuggestionMenuAfterScrolling, }), - [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions], + [ + onSelectionChange, + resetSuggestions, + setShouldBlockSuggestionCalc, + triggerHotkeyActions, + updateShouldShowSuggestionMenuToFalse, + getSuggestions, + updateShouldShowSuggestionMenuAfterScrolling, + ], ); useEffect(() => { diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index c07b693001e0..efb2d8ba73fb 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -631,6 +631,7 @@ function ReportActionItem({ action={action} draftMessage={draftMessage} reportID={report.reportID} + isGroupPolicyReport={ReportUtils.isGroupPolicy(report)} index={index} ref={textInputRef} // Avoid defining within component due to an existing Onyx bug diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index fc3c92434fc4..aeb870406adf 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -1,12 +1,12 @@ +import {PortalHost} from '@gorhom/portal'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import lodashDebounce from 'lodash/debounce'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {Keyboard, View} from 'react-native'; -import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; +import {DeviceEventEmitter, findNodeHandle, Keyboard, NativeModules, View} from 'react-native'; +import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; -import Composer from '@components/Composer'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; import Icon from '@components/Icon'; @@ -42,8 +42,13 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; +import ComposerWithSuggestionsEdit from './ReportActionCompose/ComposerWithSuggestionsEdit/ComposerWithSuggestionsEdit'; +import {useSuggestionsContext} from './ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; +import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; +const {RNTextInputReset} = NativeModules; + type ReportActionItemMessageEditProps = { /** All the data of the action */ action: OnyxTypes.ReportAction; @@ -54,6 +59,9 @@ type ReportActionItemMessageEditProps = { /** ReportID that holds the comment we're editing */ reportID: string; + /** If current composer is connected with report from group policy */ + isGroupPolicyReport: boolean; + /** Position index of the report action in the overall report FlatList view */ index: number; @@ -72,7 +80,7 @@ const isMobileSafari = Browser.isMobileSafari(); const shouldUseForcedSelectionRange = shouldUseEmojiPickerSelection(); function ReportActionItemMessageEdit( - {action, draftMessage, reportID, index, shouldDisableEmojiPicker = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE}: ReportActionItemMessageEditProps, + {action, draftMessage, reportID, isGroupPolicyReport, index, shouldDisableEmojiPicker = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE}: ReportActionItemMessageEditProps, forwardedRef: ForwardedRef<(TextInput & HTMLTextAreaElement) | undefined>, ) { const theme = useTheme(); @@ -82,6 +90,7 @@ function ReportActionItemMessageEdit( const {translate, preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {isSmallScreenWidth} = useWindowDimensions(); + const {updateCurrentActiveSuggestionsRef, clearActiveSuggestionsRef, isActiveSuggestions} = useSuggestionsContext(); const prevDraftMessage = usePrevious(draftMessage); const getInitialSelection = () => { @@ -112,6 +121,8 @@ function ReportActionItemMessageEdit( const isFocusedRef = useRef(false); const insertedEmojis = useRef([]); const draftRef = useRef(draft); + const containerRef = useRef(null); + const suggestionsRef = useRef(null); const emojiPickerSelectionRef = useRef(undefined); // The ref to check whether the comment saving is in progress const isCommentPendingSaved = useRef(false); @@ -175,7 +186,7 @@ function ReportActionItemMessageEdit( // and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style), // so we need to ensure that it is only updated after focus. if (isMobileSafari) { - setDraft((prevDraft) => { + setDraft((prevDraft: string) => { setSelection({ start: prevDraft.length, end: prevDraft.length, @@ -204,6 +215,9 @@ function ReportActionItemMessageEdit( if (ReportActionContextMenu.isActiveReportAction(action.reportActionID)) { ReportActionContextMenu.clearActiveReportAction(); } + if (isActiveSuggestions(action.reportActionID)) { + clearActiveSuggestionsRef(); + } // Show the main composer when the focused message is deleted from another client // to prevent the main composer stays hidden until we swtich to another chat. @@ -262,6 +276,7 @@ function ReportActionItemMessageEdit( if (emojis?.length > 0) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); if (newEmojis?.length > 0) { + suggestionsRef.current?.resetSuggestions(); insertedEmojis.current = [...insertedEmojis.current, ...newEmojis]; debouncedUpdateFrequentlyUsedEmojis(); } @@ -359,6 +374,10 @@ function ReportActionItemMessageEdit( */ const triggerSaveOrCancel = useCallback( (e: NativeSyntheticEvent | KeyboardEvent) => { + if (suggestionsRef.current?.triggerHotkeyActions(e as KeyboardEvent)) { + return; + } + if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { return; } @@ -374,6 +393,20 @@ function ReportActionItemMessageEdit( [deleteDraft, isKeyboardShown, isSmallScreenWidth, publishDraft], ); + const resetKeyboardInput = useCallback(() => { + if (!RNTextInputReset) { + return; + } + RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current)); + }, [textInputRef]); + + const measureContainer = useCallback((callback: MeasureInWindowOnSuccessCallback) => { + if (!containerRef.current) { + return; + } + containerRef.current.measureInWindow(callback); + }, []); + /** * Focus the composer text input */ @@ -383,9 +416,34 @@ function ReportActionItemMessageEdit( validateCommentMaxLength(draft); }, [draft, validateCommentMaxLength]); + /** + * Listen scrolling event + */ + useEffect(() => { + if (!isFocused || !suggestionsRef.current) { + return () => {}; + } + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => { + if (scrolling) { + suggestionsRef?.current?.resetSuggestions(); + return; + } + // Reopen the suggestion after scroll has end + suggestionsRef?.current?.updateShouldShowSuggestionMenuAfterScrolling(); + }); + + return () => { + scrollingListener.remove(); + }; + }, [isFocused]); + return ( <> - + + - { textInputRef.current = el; @@ -445,18 +503,33 @@ function ReportActionItemMessageEdit( if (!ReportActionContextMenu.isActiveReportAction(action.reportActionID)) { ReportActionContextMenu.clearActiveReportAction(); } + + updateCurrentActiveSuggestionsRef(suggestionsRef.current, action.reportActionID); }} onBlur={(event: NativeSyntheticEvent) => { setIsFocused(false); // @ts-expect-error TODO: TextInputFocusEventData doesn't contain relatedTarget. const relatedTargetId = event.nativeEvent?.relatedTarget?.id; + suggestionsRef.current?.resetSuggestions(); + clearActiveSuggestionsRef(); if (relatedTargetId && [messageEditInput, emojiButtonID].includes(relatedTargetId)) { return; } setShouldShowComposeInputKeyboardAware(true); }} selection={selection} - onSelectionChange={(e) => setSelection(e.nativeEvent.selection)} + onSelectionChange={(e) => { + suggestionsRef.current?.onSelectionChange?.(e); + setSelection(e.nativeEvent.selection); + }} + setValue={setDraft} + setSelection={setSelection} + isComposerFocused={!!textInputRef.current && textInputRef.current.isFocused()} + resetKeyboardInput={resetKeyboardInput} + suggestionsRef={suggestionsRef} + updateDraft={updateDraft} + measureParentContainer={measureContainer} + isGroupPolicyReport={isGroupPolicyReport} /> diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index c280a093cb13..c6383946c46b 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -33,6 +33,7 @@ import type {EmptyObject} from '@src/types/utils/EmptyObject'; import FloatingMessageCounter from './FloatingMessageCounter'; import getInitialNumToRender from './getInitialNumReportActionsToRender'; import ListBoundaryLoader from './ListBoundaryLoader'; +import {useSuggestionsContext} from './ReportActionCompose/ComposerWithSuggestionsEdit/SuggestionsContext'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; type LoadNewerChats = DebouncedFunc<(params: {distanceFromStart: number}) => void>; @@ -163,6 +164,7 @@ function ReportActionsList({ const reportScrollManager = useReportScrollManager(); const userActiveSince = useRef(null); const lastMessageTime = useRef(null); + const {currentActiveSuggestionsRef} = useSuggestionsContext(); const [isVisible, setIsVisible] = useState(false); const isFocused = useIsFocused(); @@ -646,6 +648,18 @@ function ReportActionsList({ onScrollToIndexFailed={onScrollToIndexFailed} extraData={extraData} key={listID} + onScrollBeginDrag={() => { + if (!currentActiveSuggestionsRef.current) { + return; + } + currentActiveSuggestionsRef.current.resetSuggestions(); + }} + onScrollEndDrag={() => { + if (!currentActiveSuggestionsRef.current) { + return; + } + currentActiveSuggestionsRef.current.updateShouldShowSuggestionMenuAfterScrolling(); + }} shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScrollToTopThreshold} /> diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index e9efc84e8807..1079e712747d 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -862,17 +862,18 @@ const shouldPreventScroll = shouldPreventScrollOnAutoCompleteSuggestion(); /** * Gets the correct position for auto complete suggestion container */ -function getAutoCompleteSuggestionContainerStyle(itemsHeight: number): ViewStyle { +function getAutoCompleteSuggestionContainerStyle(itemsHeight: number, shouldBeDisplayedBelowParentContainer: boolean, isEditComposer: boolean): ViewStyle { 'worklet'; const borderWidth = 2; const height = itemsHeight + 2 * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_INNER_PADDING + (shouldPreventScroll ? borderWidth : 0); + const suggestionsPadding = isEditComposer ? CONST.AUTO_COMPLETE_SUGGESTER.EDIT_SUGGESTER_PADDING : CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING; // The suggester is positioned absolutely within the component that includes the input and RecipientLocalTime view (for non-expanded mode only). To position it correctly, // we need to shift it by the suggester's height plus its padding and, if applicable, the height of the RecipientLocalTime view. return { overflow: 'hidden', - top: -(height + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + (shouldPreventScroll ? 0 : borderWidth)), + top: -(height + (shouldBeDisplayedBelowParentContainer ? -2 : 1) * (suggestionsPadding + (shouldPreventScroll ? 0 : borderWidth))), height, minHeight: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, };