diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 4ab355104f7d..8dff673dc2ab 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -509,9 +509,9 @@ "../../src/pages/inbox/report/PureReportActionItem.tsx" "react-hooks/refs" 2 "../../src/pages/inbox/report/PureReportActionItem.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/inbox/report/ReactionList/HeaderReactionList.tsx" "no-restricted-syntax" 1 -"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 2 -"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx" "react-hooks/preserve-manual-memoization" 2 -"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx" "react-hooks/refs" 8 +"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 2 +"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "react-hooks/preserve-manual-memoization" 2 +"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "react-hooks/refs" 8 "../../src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx" "react-hooks/refs" 1 "../../src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx" "react-hooks/refs" 3 "../../src/pages/inbox/report/ReportActionItemMessage.tsx" "@typescript-eslint/no-deprecated/getReportName" 1 diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1bd36b64f6a9..3f834e10d270 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -378,6 +378,13 @@ const CONST = { }, }, + // Used to track the editing state of report action messages in the ReportActionEditMessageContext provider. + REPORT_ACTION_EDIT_MESSAGE_STATE: { + OFF: 'off', + EDITING: 'editing', + SUBMITTED: 'submitted', + }, + // Maximum width and height size in px for a selected image AVATAR_MAX_WIDTH_PX: 4096, AVATAR_MAX_HEIGHT_PX: 4096, @@ -1842,6 +1849,18 @@ const CONST = { MAX_LINES_FULL: -1, // The minimum height needed to enable the full screen composer FULL_COMPOSER_MIN_HEIGHT: 60, + /** + * TestIDs for the main report composer vs inline message editor (E2E / integration tests only). + * See tests/ui/ReportActionMessageEditLayoutTest.tsx + */ + TEST_ID: { + REPORT_ACTION_COMPOSE: 'reportActionCompose', + DRAFT_MESSAGE_ACTION_ROW: 'reportActionCompose_draftMessageActionRow', + EDITING_MESSAGE_ACTION_ROW: 'reportActionCompose_editingMessageActionRow', + REPORT_ACTION_ITEM_MESSAGE_EDIT: 'reportActionItemMessageEdit', + MESSAGE_EDIT_CANCEL_MAIN_COMPOSER: 'messageEditCancel_mainComposer', + MESSAGE_EDIT_CANCEL_INLINE: 'messageEditCancel_inlineMessageEdit', + }, }, MODAL: { MODAL_TYPE: { @@ -1897,6 +1916,7 @@ const CONST = { TEST_TOOLS_MODAL_THROTTLE_TIME: 800, TOOLTIP_SENSE: 1000, COMMENT_LENGTH_DEBOUNCE_TIME: 1500, + DRAFT_SAVE_DEBOUNCE_TIME: 1000, SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300, ACCESSIBILITY_ANNOUNCEMENT_DEBOUNCE_TIME: 1000, SUGGESTION_DEBOUNCE_TIME: 100, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 6d05c4c56124..ab1332db0b2d 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -423,9 +423,6 @@ const ONYXKEYS = { /** The policyID of the last workspace whose settings were accessed by the user */ LAST_ACCESSED_WORKSPACE_POLICY_ID: 'lastAccessedWorkspacePolicyID', - /** Whether we should show the compose input or not */ - SHOULD_SHOW_COMPOSE_INPUT: 'shouldShowComposeInput', - /** Is app in beta version */ IS_BETA: 'isBeta', @@ -1486,7 +1483,6 @@ type OnyxValuesMapping = { [ONYXKEYS.HAS_LOADED_APP]: boolean; [ONYXKEYS.WALLET_TRANSFER]: OnyxTypes.WalletTransfer; [ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID]: string; - [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: boolean; [ONYXKEYS.IS_BETA]: boolean; [ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM]: boolean; [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record; diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 5d235e54dfd1..c5f57b956fc6 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -1,9 +1,9 @@ -import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; +import type {MarkdownStyle, MarkdownTextInput} from '@expensify/react-native-live-markdown'; import mimeDb from 'mime-db'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent, TextInputChangeEvent, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; -import type {ComposerProps} from '@components/Composer/types'; +import type {ComposerProps, ComposerRef} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; @@ -38,7 +38,7 @@ function Composer({ ref, ...props }: ComposerProps) { - const textInput = useRef(null); + const textInputRef = useRef(null); const textContainsOnlyEmojis = useMemo(() => containsOnlyEmojis(Parser.htmlToText(Parser.replace(value ?? ''))), [value]); const theme = useTheme(); const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); @@ -47,7 +47,7 @@ function Composer({ const isInLandscapeMode = useIsInLandscapeMode(); useEffect(() => { - if (!textInput.current?.setSelection || !selection || isComposerFullSize) { + if (!textInputRef.current?.setSelection || !selection || isComposerFullSize) { return; } @@ -56,8 +56,8 @@ function Composer({ // (see https://github.com/Expensify/App/pull/50520#discussion_r1861960311 for more context) const timeoutID = setTimeout(() => { // We are setting selection twice to trigger a scroll to the cursor on toggling to smaller composer size. - textInput.current?.setSelection((selection.start || 1) - 1, selection.start); - textInput.current?.setSelection(selection.start, selection.start); + textInputRef.current?.setSelection((selection.start || 1) - 1, selection.start); + textInputRef.current?.setSelection(selection.start, selection.start); }, 0); return () => clearTimeout(timeoutID); @@ -71,9 +71,9 @@ function Composer({ */ const setTextInputRef = useCallback( (el: AnimatedMarkdownTextInputRef | null) => { - textInput.current = isInLandscapeMode ? getLandscapeTextInputRefProxy(el) : el; + textInputRef.current = isInLandscapeMode ? getLandscapeTextInputRefProxy(el) : el; - if (typeof ref !== 'function' || textInput.current === null) { + if (typeof ref !== 'function' || textInputRef.current === null) { return; } @@ -81,7 +81,7 @@ function Composer({ // get a ref to the inner textInput element e.g. if we do // this.textInput = el} /> this will not // return a ref to the component, but rather the HTML element by default - ref(textInput.current); + ref(textInputRef.current as ComposerRef); }, // eslint-disable-next-line react-hooks/exhaustive-deps [isInLandscapeMode], diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 36b1b2fe5780..e5516cfb4c8d 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -4,7 +4,7 @@ import lodashDebounce from 'lodash/debounce'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; import {DeviceEventEmitter, StyleSheet} from 'react-native'; -import type {ComposerProps} from '@components/Composer/types'; +import type {ComposerProps, ComposerRef} from '@components/Composer/types'; import {useSession} from '@components/OnyxListItemProvider'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; @@ -64,7 +64,7 @@ function Composer({ const addAuthTokenToImageURL = useCallback((url: string) => addEncryptedAuthTokenToURL(url, encryptedAuthToken), [encryptedAuthToken]); const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const StyleUtils = useStyleUtils(); - const textInput = useRef(null); + const textInputRef = useRef(null); const [selection, setSelection] = useState< | { start: number; @@ -79,7 +79,7 @@ function Composer({ }); const [isRendered, setIsRendered] = useState(false); - const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); + const isScrollBarVisible = useIsScrollBarVisible(textInputRef, value ?? ''); const [prevScroll, setPrevScroll] = useState(); const [prevHeight, setPrevHeight] = useState(); const isReportFlatListScrolling = useRef(false); @@ -95,19 +95,26 @@ function Composer({ /** * Adds the cursor position to the selection change event. */ - const addCursorPositionToSelectionChange = (event: TextInputSelectionChangeEvent) => { - const sel = window.getSelection(); - if (shouldCalculateCaretPosition && isRendered && sel) { + const addCursorPositionToSelectionChange = useCallback( + (event: TextInputSelectionChangeEvent) => { + const sel = window.getSelection(); + const canCalculateCaretPosition = shouldCalculateCaretPosition && isRendered && sel; + if (!canCalculateCaretPosition) { + onSelectionChange(event); + setSelection(event.nativeEvent.selection); + return; + } + const range = sel.getRangeAt(0).cloneRange(); range.collapse(true); const rect = range.getClientRects()[0] || range.startContainer.parentElement?.getClientRects()[0]; - const containerRect = textInput.current?.getBoundingClientRect(); + const containerRect = textInputRef.current?.getBoundingClientRect(); let x = 0; let y = 0; if (rect && containerRect) { x = rect.left - containerRect.left; - y = rect.top - containerRect.top + (textInput?.current?.scrollTop ?? 0) - rect.height / 2; + y = rect.top - containerRect.top + (textInputRef?.current?.scrollTop ?? 0) - rect.height / 2; } const selectionValue = { @@ -125,11 +132,9 @@ function Composer({ }, }); setSelection(selectionValue); - } else { - onSelectionChange(event); - setSelection(event.nativeEvent.selection); - } - }; + }, + [isRendered, onSelectionChange, shouldCalculateCaretPosition], + ); /** * Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file, @@ -138,14 +143,14 @@ function Composer({ const handlePaste = useCallback( (event: ClipboardEvent) => { const isVisible = checkComposerVisibility(); - const isFocused = textInput.current?.isFocused(); + const isFocused = textInputRef.current?.isFocused(); const isContenteditableDivFocused = document.activeElement?.nodeName === 'DIV' && document.activeElement?.hasAttribute('contenteditable'); if (!(isVisible || isFocused)) { return true; } - if (textInput.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) { + if (textInputRef.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) { const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null; // To make sure the composer does not capture paste events from other inputs, we check where the event originated // If it did originate in another input, we return early to prevent the composer from handling the paste @@ -154,7 +159,7 @@ function Composer({ return true; } - textInput.current?.focus(); + textInputRef.current?.focus(); } event.preventDefault(); @@ -209,19 +214,23 @@ function Composer({ ); useEffect(() => { - if (!textInput.current) { + if (!textInputRef.current) { return; } + + const inputRef = textInputRef.current; + const debouncedSetPrevScroll = lodashDebounce(() => { - if (!textInput.current) { + if (!inputRef) { return; } - setPrevScroll(textInput.current.scrollTop); + setPrevScroll(inputRef.scrollTop); }, 100); - textInput.current.addEventListener('scroll', debouncedSetPrevScroll); + inputRef.addEventListener('scroll', debouncedSetPrevScroll); + return () => { - textInput.current?.removeEventListener('scroll', debouncedSetPrevScroll); + inputRef?.removeEventListener('scroll', debouncedSetPrevScroll); }; }, []); @@ -234,6 +243,8 @@ function Composer({ }, []); useEffect(() => { + const inputRef = textInputRef.current; + const handleWheel = (e: MouseEvent) => { if (isReportFlatListScrolling.current) { e.preventDefault(); @@ -242,40 +253,40 @@ function Composer({ // When the composer has no scrollable content, the stopPropagation will prevent the inverted wheel event handler on the Chat body // which defaults to the browser wheel behavior. This causes the chat body to scroll in the opposite direction creating jerky behavior. - if (textInput.current && textInput.current.scrollHeight <= textInput.current.clientHeight) { + if (inputRef && inputRef.scrollHeight <= inputRef.clientHeight) { return; } e.stopPropagation(); }; - textInput.current?.addEventListener('wheel', handleWheel, {passive: false}); + inputRef?.addEventListener('wheel', handleWheel, {passive: false}); return () => { - textInput.current?.removeEventListener('wheel', handleWheel); + inputRef?.removeEventListener('wheel', handleWheel); }; }, []); useEffect(() => { - if (!textInput.current || prevScroll === undefined || prevHeight === undefined) { + if (!textInputRef.current || prevScroll === undefined || prevHeight === undefined) { return; } - textInput.current.scrollTop = prevScroll + prevHeight - textInput.current.clientHeight; + textInputRef.current.scrollTop = prevScroll + prevHeight - textInputRef.current.clientHeight; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isComposerFullSize]); const isActive = useIsFocused(); - useHtmlPaste(textInput, handlePaste, isActive); + useHtmlPaste(textInputRef, handlePaste, isActive); useEffect(() => { setIsRendered(true); }, []); const clear = useCallback(() => { - if (!textInput.current) { + if (!textInputRef.current) { return; } - const currentText = textInput.current.value; - textInput.current.clear(); + const currentText = textInputRef.current.value; + textInputRef.current.clear(); // We need to reset the selection to 0,0 manually after clearing the text input on web const selectionEvent = { @@ -293,22 +304,22 @@ function Composer({ }, [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.'); + const textInput = textInputRef.current; + if (!textInput) { + throw new Error('textInput is not available. This should never happen and indicates a developer error.'); } return { - ...textInputRef, + ...textInput, // 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(), + blur: () => textInput.blur(), + focus: () => textInput.focus(), get scrollTop() { - return textInputRef.scrollTop; + return textInput.scrollTop; }, - }; + } as ComposerRef; }, [clear]); const handleKeyPress = useCallback( @@ -350,9 +361,7 @@ function Composer({ autoComplete="off" autoCorrect={!isMobileSafari()} placeholderTextColor={theme.placeholderText} - ref={(el) => { - textInput.current = el; - }} + ref={textInputRef} selection={selection} style={[inputStyleMemo]} markdownStyle={markdownStyle} diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 8b408373bbcf..63d8a079be77 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -1,6 +1,6 @@ +import type {MarkdownTextInput} from '@expensify/react-native-live-markdown'; import type {Ref} from 'react'; -import type {StyleProp, TextInput, TextInputProps, TextInputSelectionChangeEvent, TextStyle} from 'react-native'; -import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; +import type {StyleProp, TextInputProps, TextInputSelectionChangeEvent, TextStyle} from 'react-native'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -15,6 +15,8 @@ type CustomSelectionChangeEvent = TextInputSelectionChangeEvent & { positionY?: number; }; +type ComposerRef = MarkdownTextInput & HTMLInputElement & HTMLTextAreaElement; + type ComposerProps = Omit & ForwardedFSClassProps & { /** Indicate whether input is multiline */ @@ -74,7 +76,7 @@ type ComposerProps = Omit & isGroupPolicyReport?: boolean; /** Ref exposing imperative methods on the underlying text input */ - ref?: Ref; + ref?: Ref; }; -export type {TextSelection, ComposerProps, CustomSelectionChangeEvent}; +export type {TextSelection, ComposerProps, CustomSelectionChangeEvent, ComposerRef}; diff --git a/src/components/ExceededCommentLength.tsx b/src/components/ExceededCommentLength.tsx index b83e57c3b7e8..17902875a7e2 100644 --- a/src/components/ExceededCommentLength.tsx +++ b/src/components/ExceededCommentLength.tsx @@ -9,7 +9,7 @@ type ExceededCommentLengthProps = { isTaskTitle?: boolean; }; -function ExceededCommentLength({maxCommentLength = CONST.MAX_COMMENT_LENGTH, isTaskTitle}: ExceededCommentLengthProps) { +function ExceededCommentLength({maxCommentLength = CONST.MAX_COMMENT_LENGTH, isTaskTitle = false}: ExceededCommentLengthProps) { const styles = useThemeStyles(); const {numberFormat, translate} = useLocalize(); diff --git a/src/components/ImageSVG/index.android.tsx b/src/components/ImageSVG/index.android.tsx index 298703d35217..a15fc682f5f6 100644 --- a/src/components/ImageSVG/index.android.tsx +++ b/src/components/ImageSVG/index.android.tsx @@ -5,7 +5,6 @@ import getImageRecyclingKey from '@libs/getImageRecyclingKey'; import type ImageSVGProps from './types'; function ImageSVG({src, width = '100%', height = '100%', fill, contentFit = 'cover', style, onLoadEnd}: ImageSVGProps) { - const tintColorProp = fill ? {tintColor: fill} : {}; const isReactComponent = typeof src === 'function'; // Clear memory cache when unmounting images to avoid memory overload @@ -60,7 +59,10 @@ function ImageSVG({src, width = '100%', height = '100%', fill, contentFit = 'cov source={src} recyclingKey={getImageRecyclingKey(src)} style={[{width, height}, style as ExpoImageProps['style']]} - {...tintColorProp} + tintColor={fill} + // On android, there's an issue where the fill color of the icon does not change, + // unless the component is remounted. (https://github.com/Expensify/App/pull/76741#issuecomment-4245274687) + key={fill} /> ); } diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 628ea0baca68..7a607051dfe4 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -44,7 +44,7 @@ import { isReportActionVisible, wasMessageReceivedWhileOffline, } from '@libs/ReportActionsUtils'; -import {canUserPerformWriteAction, chatIncludesChronosWithID, getOriginalReportID, getReportLastVisibleActionCreated, isHarvestCreatedExpenseReport, isUnread} from '@libs/ReportUtils'; +import {canUserPerformWriteAction, chatIncludesChronosWithID, getReportLastVisibleActionCreated, isHarvestCreatedExpenseReport, isUnread} from '@libs/ReportUtils'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import Visibility from '@libs/Visibility'; @@ -132,15 +132,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - // reportActions is passed as an array because it's sorted chronologically for FlatList rendering and pagination. - // However, getOriginalReportID expects the Onyx object format (keyed by reportActionID) for efficient lookups. - const reportActionsObject = useMemo(() => { - const obj: OnyxTypes.ReportActions = {}; - for (const action of reportActions) { - obj[action.reportActionID] = action; - } - return obj; - }, [reportActions]); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], false, reportTransactionIDs); const firstVisibleReportActionID = useMemo(() => getFirstVisibleReportActionID(reportActions, isOffline), [reportActions, isOffline]); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); @@ -562,8 +554,6 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && hasNextActionMadeBySameActor(visibleReportActions, index, isOffline); - const originalReportID = getOriginalReportID(report?.reportID, reportAction, reportActionsObject); - return ( ({ index={item.index ?? 0} isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} - shouldShowDraftMessage={false} shouldShowBorder personalDetails={personalDetails} userBillingFundID={userBillingFundID} diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index a5438785295a..639f917d7abe 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line lodash/import-scope import type {DebouncedFunc, DebounceSettings} from 'lodash'; import lodashDebounce from 'lodash/debounce'; -import {useCallback, useEffect, useRef} from 'react'; +import {useEffect, useRef} from 'react'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type GenericFunction = (...args: any[]) => void; @@ -34,13 +34,13 @@ export default function useDebounce(func: T, wait: nu }; }, [func, wait, leading, maxWait, trailing]); - const debounceCallback = useCallback((...args: Parameters) => { + const debounceCallback = (...args: Parameters) => { const debouncedFn = debouncedFnRef.current; if (debouncedFn) { debouncedFn(...args); } - }, []); + }; return debounceCallback as T; } diff --git a/src/hooks/useHandleExceedMaxCommentLength.ts b/src/hooks/useHandleExceedMaxCommentLength.ts deleted file mode 100644 index cec96b4ca8f6..000000000000 --- a/src/hooks/useHandleExceedMaxCommentLength.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {useCallback, useState} from 'react'; -import {getCommentLength} from '@libs/ReportUtils'; -import type {ParsingDetails} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; - -const useHandleExceedMaxCommentLength = () => { - const [hasExceededMaxCommentLength, setHasExceededMaxCommentLength] = useState(false); - - const validateCommentMaxLength = useCallback((value: string, parsingDetails?: ParsingDetails) => { - const exceeded = getCommentLength(value, parsingDetails) > CONST.MAX_COMMENT_LENGTH; - setHasExceededMaxCommentLength(exceeded); - return !exceeded; - }, []); - - return {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength}; -}; - -export default useHandleExceedMaxCommentLength; diff --git a/src/hooks/useHandleExceedMaxTaskTitleLength.ts b/src/hooks/useHandleExceedMaxTaskTitleLength.ts deleted file mode 100644 index 75acd73280a4..000000000000 --- a/src/hooks/useHandleExceedMaxTaskTitleLength.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {useCallback, useState} from 'react'; -import CONST from '@src/CONST'; - -const useHandleExceedMaxTaskTitleLength = () => { - const [hasExceededMaxTaskTitleLength, setHasExceededMaxTitleLength] = useState(false); - - const validateTaskTitleMaxLength = useCallback((title: string) => { - const exceeded = title ? title.length > CONST.TITLE_CHARACTER_LIMIT : false; - setHasExceededMaxTitleLength(exceeded); - return !exceeded; - }, []); - - return {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength}; -}; - -export default useHandleExceedMaxTaskTitleLength; diff --git a/src/hooks/useTransactionThreadReport.ts b/src/hooks/useTransactionThreadReport.ts index f8d64f58642b..efe75fdf699a 100644 --- a/src/hooks/useTransactionThreadReport.ts +++ b/src/hooks/useTransactionThreadReport.ts @@ -1,11 +1,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID} from '@libs/ReportActionsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; -import usePaginatedReportActions from './usePaginatedReportActions'; -import useReportTransactionsCollection from './useReportTransactionsCollection'; +import useTransactionThreadReportID from './useTransactionThreadReportID'; /** * Derives the single-transaction thread report ID and report for a money request report. @@ -14,20 +10,8 @@ import useReportTransactionsCollection from './useReportTransactionsCollection'; * whether a report has a single transaction thread (and access its data). */ function useTransactionThreadReport(reportID: string | undefined) { - const {isOffline} = useNetwork(); + const {transactionThreadReportID, reportActions} = useTransactionThreadReportID(reportID); - const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); - - const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); - const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); - - const allReportTransactions = useReportTransactionsCollection(reportID); - const nonDeletedTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true); - const visibleTransactions = nonDeletedTransactions?.filter((t) => isOffline || t.pendingAction !== 'delete'); - const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); - - const transactionThreadReportID = getOneTransactionThreadReportID(moneyRequestReport, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionThreadReportID)}`); return { diff --git a/src/hooks/useTransactionThreadReportID.ts b/src/hooks/useTransactionThreadReportID.ts new file mode 100644 index 000000000000..6795b086cf30 --- /dev/null +++ b/src/hooks/useTransactionThreadReportID.ts @@ -0,0 +1,38 @@ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePaginatedReportActions from './usePaginatedReportActions'; +import useReportTransactionsCollection from './useReportTransactionsCollection'; + +/** + * Derives the single-transaction thread report IDs and filtered actions for a money request report. + */ +function useTransactionThreadReportID(reportID: string | undefined) { + const {isOffline} = useNetwork(); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); + + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + const allReportTransactions = useReportTransactionsCollection(reportID); + const nonDeletedTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true); + const visibleTransactions = nonDeletedTransactions?.filter((t) => isOffline || t.pendingAction !== 'delete'); + const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); + + const transactionThreadReportID = getOneTransactionThreadReportID(moneyRequestReport, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); + const isSentMoneyReport = reportActions.some((action) => isSentMoneyReportAction(action)); + const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; + + return { + transactionThreadReportID, + effectiveTransactionThreadReportID, + reportActions, + }; +} + +export default useTransactionThreadReportID; diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts index 98e46daf7f80..8ee44f3f8318 100644 --- a/src/libs/ReportActionComposeFocusManager.ts +++ b/src/libs/ReportActionComposeFocusManager.ts @@ -1,7 +1,8 @@ import {findFocusedRoute} from '@react-navigation/native'; -import type {RefObject} from 'react'; import React from 'react'; +import type {RefObject} from 'react'; import type {TextInput} from 'react-native'; +import type {ComposerRef} from '@components/Composer/types'; import SCREENS from '@src/SCREENS'; import isReportOpenInRHP from './Navigation/helpers/isReportOpenInRHP'; import navigationRef from './Navigation/navigationRef'; @@ -11,15 +12,16 @@ type ComposerType = 'main' | 'edit'; type FocusCallback = (shouldFocusForNonBlurInputOnTapOutside?: boolean) => void; -const composerRef: RefObject = React.createRef(); +// There are two types of composer: general composer (edit composer) and main composer. +// The general composer callback will take priority if it exists. +const composerRef = React.createRef(); +const editComposerRef = React.createRef(); + /** * There can be 2 composers present at the same time. This ref is for the side panel. */ const sidePanelComposerRef: RefObject = React.createRef(); -// There are two types of composer: general composer (edit composer) and main composer. -// The general composer callback will take priority if it exists. -const editComposerRef: RefObject = React.createRef(); // There are two types of focus callbacks: priority and general // Priority callback would take priority if it existed let priorityFocusCallback: FocusCallback | null = null; diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index f514cce19454..e8d8d9c49ccf 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -131,7 +131,6 @@ const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.MODAL, ONYXKEYS.NETWORK, ONYXKEYS.SESSION, - ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, ONYXKEYS.NVP_PREFERRED_LOCALE, diff --git a/src/libs/actions/Composer.ts b/src/libs/actions/Composer.ts deleted file mode 100644 index edd42fb801f2..000000000000 --- a/src/libs/actions/Composer.ts +++ /dev/null @@ -1,11 +0,0 @@ -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '@src/ONYXKEYS'; - -function setShouldShowComposeInput(shouldShowComposeInput: boolean) { - Onyx.set(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, shouldShowComposeInput); -} - -export { - // eslint-disable-next-line import/prefer-default-export - setShouldShowComposeInput, -}; diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts index 1c4bee516c69..5230c6dd1240 100644 --- a/src/libs/actions/QueuedOnyxUpdates.ts +++ b/src/libs/actions/QueuedOnyxUpdates.ts @@ -48,7 +48,6 @@ function flushQueue(): Promise { ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, ONYXKEYS.MODAL, ONYXKEYS.NETWORK, - ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, ONYXKEYS.PRESERVED_USER_SESSION, ]); diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index af39693d4d0d..3843119c105d 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -3304,16 +3304,27 @@ function editReportComment( ); } -/** Deletes the draft for a comment report action. */ -function deleteReportActionDraft(reportID: string | undefined, reportAction: ReportAction) { - const originalReportID = getOriginalReportID(reportID, reportAction, undefined); - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`, {[reportAction.reportActionID]: null}); +/** Clears drafts for all comment report action across all reports */ +function clearAllReportActionDrafts() { + Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {}); } /** Saves the draft for a comment report action. This will put the comment into "edit mode" */ -function saveReportActionDraft(reportID: string | undefined, reportAction: ReportAction, draftMessage: string) { +function saveReportActionDraft(reportID: string | undefined, reportAction: ReportAction | null, draftMessage: string) { + if (!reportAction) { + return; + } + const originalReportID = getOriginalReportID(reportID, reportAction, undefined); - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`, {[reportAction.reportActionID]: {message: draftMessage}}); + if (!originalReportID) { + return; + } + + Onyx.setCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`]: { + [reportAction.reportActionID]: {message: draftMessage}, + }, + }); } function updateNotificationPreference( @@ -7870,7 +7881,7 @@ export { extractRHPVariantFromResponse, createNewReport, deleteReport, - deleteReportActionDraft, + clearAllReportActionDrafts, deleteReportComment, deleteReportField, dismissTrackExpenseActionableWhisper, diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index 1f18a4b9d4bc..dbbcfae1d965 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -2,6 +2,7 @@ import ComposerFocusManager from '@libs/ComposerFocusManager'; import isWindowReadyToFocus from '@libs/isWindowReadyToFocus'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; +import requestKeyboardForFocusedComposer from './requestKeyboardForFocusedComposer'; import setTextInputSelection from './setTextInputSelection'; import type {FocusComposerWithDelay, InputType} from './types'; @@ -9,36 +10,54 @@ import type {FocusComposerWithDelay, InputType} from './types'; * Create a function that focuses the composer. */ function focusComposerWithDelay(textInput: InputType | null, delay: number = CONST.COMPOSER_FOCUS_DELAY): FocusComposerWithDelay { + function getIsFocused() { + if (textInput && 'isFocused' in textInput) { + return textInput.isFocused(); + } + return false; + } + /** * Focus the text input * @param [shouldDelay] Impose delay before focusing the text input * @param [forcedSelectionRange] Force selection range of text input + * @param [forceKeyboardIfAlreadyFocused] Use KeyboardController so the soft keyboard can show without blur/refocus */ - return (shouldDelay = false, forcedSelectionRange = undefined) => { + return async (shouldDelay = false, forcedSelectionRange = undefined, forceKeyboardIfAlreadyFocused = false) => { // There could be other animations running while we trigger manual focus. // This prevents focus from making those animations janky. if (!textInput || EmojiPickerAction.isEmojiPickerVisible()) { return; } - if (!shouldDelay) { - textInput.focus(); - if (forcedSelectionRange) { - setTextInputSelection(textInput, forcedSelectionRange); - } - return; - } - Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]).then(() => { - if (!textInput) { + function focusAndUpdateSelection(input: InputType) { + if (getIsFocused()) { + if (forceKeyboardIfAlreadyFocused) { + requestKeyboardForFocusedComposer(input, forcedSelectionRange); + } return; } - // When the closing modal has a focused text input focus() needs a delay to properly work. - // Setting 150ms here is a temporary workaround for the Android HybridApp. It should be reverted once we identify the real root cause of this issue: https://github.com/Expensify/App/issues/56311. - setTimeout(() => textInput.focus(), delay); + + input.focus(); if (forcedSelectionRange) { - setTextInputSelection(textInput, forcedSelectionRange); + setTextInputSelection(input, forcedSelectionRange); } - }); + } + + if (!shouldDelay) { + focusAndUpdateSelection(textInput); + return; + } + + await Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]); + + if (!textInput) { + return; + } + + // When the closing modal has a focused text input focus() needs a delay to properly work. + // Setting 150ms here is a temporary workaround for the Android HybridApp. It should be reverted once we identify the real root cause of this issue: https://github.com/Expensify/App/issues/56311. + setTimeout(() => focusAndUpdateSelection(textInput), delay); }; } diff --git a/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.native.ts b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.native.ts new file mode 100644 index 000000000000..723e73257307 --- /dev/null +++ b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.native.ts @@ -0,0 +1,21 @@ +import {KeyboardController} from 'react-native-keyboard-controller'; +import setTextInputSelection from '@libs/focusComposerWithDelay/setTextInputSelection'; +import type RequestKeyboardForFocusedComposer from './types'; + +/** + * When the field already has focus, RN's `focus()` often does not show the IME again. + * `KeyboardController.setFocusTo('current')` re-applies focus via native (`requestFocusFromJS` on Android, + * `reloadInputViews` + `focus` on iOS) without blurring first. + * + * @see https://kirillzyusko.github.io/react-native-keyboard-controller/docs/api/keyboard-controller#setfocusto + */ +const requestKeyboardForFocusedComposer: RequestKeyboardForFocusedComposer = (textInput, forcedSelectionRange) => { + requestIdleCallback(() => { + KeyboardController.setFocusTo('current'); + if (forcedSelectionRange) { + setTextInputSelection(textInput, forcedSelectionRange); + } + }); +}; + +export default requestKeyboardForFocusedComposer; diff --git a/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.ts b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.ts new file mode 100644 index 000000000000..8470a7ea6846 --- /dev/null +++ b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/index.ts @@ -0,0 +1,7 @@ +import type RequestKeyboardForFocusedComposer from './types'; + +const NOOP = () => {}; + +const requestKeyboardForFocusedComposer: RequestKeyboardForFocusedComposer = NOOP; + +export default requestKeyboardForFocusedComposer; diff --git a/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/types.ts b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/types.ts new file mode 100644 index 000000000000..5464de49d563 --- /dev/null +++ b/src/libs/focusComposerWithDelay/requestKeyboardForFocusedComposer/types.ts @@ -0,0 +1,5 @@ +import type {InputType, Selection} from '@libs/focusComposerWithDelay/types'; + +type RequestKeyboardForFocusedComposer = (textInput: InputType, forcedSelectionRange?: Selection) => void; + +export default RequestKeyboardForFocusedComposer; diff --git a/src/libs/focusComposerWithDelay/types.ts b/src/libs/focusComposerWithDelay/types.ts index 97a1298e8c7a..1447161e88f2 100644 --- a/src/libs/focusComposerWithDelay/types.ts +++ b/src/libs/focusComposerWithDelay/types.ts @@ -7,7 +7,7 @@ type Selection = { positionY?: number; }; -type FocusComposerWithDelay = (shouldDelay?: boolean, forcedSelectionRange?: Selection) => void; +type FocusComposerWithDelay = (shouldDelay?: boolean, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused?: boolean) => Promise; type InputType = TextInput | HTMLTextAreaElement; diff --git a/src/libs/refocusComposerAfterPreventFirstResponder.ts b/src/libs/refocusComposerAfterPreventFirstResponder.ts index 6ac3fca6dcbc..e9fe1bdb1eaa 100644 --- a/src/libs/refocusComposerAfterPreventFirstResponder.ts +++ b/src/libs/refocusComposerAfterPreventFirstResponder.ts @@ -1,15 +1,17 @@ -import isWindowReadyToFocus from './isWindowReadyToFocus'; +import type {ComposerRef} from '@components/Composer/types'; +import focusComposerWithDelay from './focusComposerWithDelay'; import type {ComposerType} from './ReportActionComposeFocusManager'; import ReportActionComposeFocusManager from './ReportActionComposeFocusManager'; function refocusComposerAfterPreventFirstResponder(composerToRefocusOnClose: ComposerType | undefined) { - return isWindowReadyToFocus().then(() => { - if (composerToRefocusOnClose === 'main') { - ReportActionComposeFocusManager.composerRef.current?.focus(); - } else if (composerToRefocusOnClose === 'edit') { - ReportActionComposeFocusManager.editComposerRef.current?.focus(); - } - }); + let composerRef: ComposerRef | null = null; + if (composerToRefocusOnClose === 'main') { + composerRef = ReportActionComposeFocusManager.composerRef.current; + } else if (composerToRefocusOnClose === 'edit') { + composerRef = ReportActionComposeFocusManager.editComposerRef.current; + } + + return focusComposerWithDelay(composerRef)(true); } export default refocusComposerAfterPreventFirstResponder; diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.android.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.android.ts deleted file mode 100644 index 68c750b05a5f..000000000000 --- a/src/libs/setShouldShowComposeInputKeyboardAware/index.android.ts +++ /dev/null @@ -1,3 +0,0 @@ -import setShouldShowComposeInputKeyboardAwareBuilder from './setShouldShowComposeInputKeyboardAwareBuilder'; - -export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardDidHide'); diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts deleted file mode 100644 index 68c750b05a5f..000000000000 --- a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts +++ /dev/null @@ -1,3 +0,0 @@ -import setShouldShowComposeInputKeyboardAwareBuilder from './setShouldShowComposeInputKeyboardAwareBuilder'; - -export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardDidHide'); diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.ts deleted file mode 100644 index b3a2e7542148..000000000000 --- a/src/libs/setShouldShowComposeInputKeyboardAware/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as Composer from '@userActions/Composer'; -import type SetShouldShowComposeInputKeyboardAware from './types'; - -const setShouldShowComposeInputKeyboardAware: SetShouldShowComposeInputKeyboardAware = (shouldShow) => { - // We want to show the main composer when the edit composer loses focus. - // If it loses focus due to a pressable being pressed, the press event might not be captured. - // To address this, we delay showing the main composer to allow the press event to be completed. - setTimeout(() => { - Composer.setShouldShowComposeInput(shouldShow); - }, 0); -}; - -export default setShouldShowComposeInputKeyboardAware; diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts deleted file mode 100644 index 72df7a730e02..000000000000 --- a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type {EmitterSubscription} from 'react-native'; -import {Keyboard} from 'react-native'; -import type {KeyboardEventName} from 'react-native/Libraries/Components/Keyboard/Keyboard'; -import * as Composer from '@userActions/Composer'; -import type SetShouldShowComposeInputKeyboardAware from './types'; - -let keyboardEventListener: EmitterSubscription | null = null; - -const setShouldShowComposeInputKeyboardAwareBuilder: (keyboardEvent: KeyboardEventName) => SetShouldShowComposeInputKeyboardAware = - (keyboardEvent: KeyboardEventName) => (shouldShow: boolean) => { - if (keyboardEventListener) { - keyboardEventListener.remove(); - keyboardEventListener = null; - } - - if (!shouldShow) { - Composer.setShouldShowComposeInput(false); - return; - } - - // If keyboard is already hidden, we should show composer immediately because keyboardDidHide event won't be called - if (!Keyboard.isVisible()) { - Composer.setShouldShowComposeInput(true); - return; - } - - keyboardEventListener = Keyboard.addListener(keyboardEvent, () => { - Composer.setShouldShowComposeInput(true); - keyboardEventListener?.remove(); - }); - }; - -export default setShouldShowComposeInputKeyboardAwareBuilder; diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/types.ts b/src/libs/setShouldShowComposeInputKeyboardAware/types.ts deleted file mode 100644 index 7e3a604f562e..000000000000 --- a/src/libs/setShouldShowComposeInputKeyboardAware/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -type SetShouldShowComposeInputKeyboardAware = (shouldShow: boolean) => void; - -export default SetShouldShowComposeInputKeyboardAware; diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index aae41bdc2d11..b9146ca78154 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -18,7 +18,6 @@ import useDocumentTitle from '@hooks/useDocumentTitle'; import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; import usePrevious from '@hooks/usePrevious'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -26,25 +25,21 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSubmitToDestinationVisible from '@hooks/useSubmitToDestinationVisible'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useTransactionThreadReportID from '@hooks/useTransactionThreadReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Log from '@libs/Log'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; -import { - getFilteredReportActionsForReportView, - getIOUActionForTransactionID, - getOneTransactionThreadReportID, - getOriginalMessage, - getReportAction, - isMoneyRequestAction, -} from '@libs/ReportActionsUtils'; +import {getIOUActionForTransactionID, getOriginalMessage, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; import {isMoneyRequestReportPendingDeletion, isValidReportIDFromPath} from '@libs/ReportUtils'; import {cancelSpansByPrefix} from '@libs/telemetry/activeSpans'; import {doesDeleteNavigateBackUrlIncludeDuplicatesReview, getParentReportActionDeletionStatus, hasLoadedReportActions, isThreadReportDeleted} from '@libs/TransactionNavigationUtils'; import Navigation from '@navigation/Navigation'; import ReactionListWrapper from '@pages/inbox/ReactionListWrapper'; +import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; +import useClearReportActionDraftsOnReportChange from '@pages/inbox/report/useClearReportActionDraftsOnReportChange'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import {clearDeleteTransactionNavigateBackUrl, createTransactionThreadReport, openReport, updateLastVisitTime} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -122,19 +117,15 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { const actionListValue = useActionListContextValue(); - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - const {reportActions: unfilteredReportActions} = usePaginatedReportActions(reportIDFromRoute); const {transactions: allReportTransactions, violations: allReportViolations} = useTransactionsAndViolationsForReport(reportIDFromRoute); - const reportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); + const {transactionThreadReportID, effectiveTransactionThreadReportID, reportActions} = useTransactionThreadReportID(reportIDFromRoute); const reportTransactions = useMemo(() => getAllNonDeletedTransactions(allReportTransactions, reportActions), [allReportTransactions, reportActions]); const visibleTransactions = useMemo( () => reportTransactions?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), [reportTransactions, isOffline], ); - const reportTransactionIDs = useMemo(() => visibleTransactions?.map((transaction) => transaction.transactionID), [visibleTransactions]); - const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); const oneTransactionID = reportTransactions.at(0)?.transactionID; const reportID = report?.reportID; @@ -376,39 +367,46 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { shouldUseSnapshotTransaction, ]); + useClearReportActionDraftsOnReportChange(reportIDFromRoute); + return ( - - - - - + + + + - - - - - - - - - + + + + + + + + + + + ); } diff --git a/src/pages/inbox/ReportFetchHandler.tsx b/src/pages/inbox/ReportFetchHandler.tsx index 03a946050f2d..54456375f6b0 100644 --- a/src/pages/inbox/ReportFetchHandler.tsx +++ b/src/pages/inbox/ReportFetchHandler.tsx @@ -18,7 +18,6 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import {getFilteredReportActionsForReportView, getIOUActionForReportID, getOneTransactionThreadReportID, isCreatedAction} from '@libs/ReportActionsUtils'; import {isChatThread, isHiddenForCurrentUser, isOneTransactionThread, isPolicyExpenseChat, isReportTransactionThread, isTaskReport, isValidReportIDFromPath} from '@libs/ReportUtils'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@navigation/types'; -import {setShouldShowComposeInput} from '@userActions/Composer'; import { clearStaleDMRecoveryTargetByTargetReportID, createTransactionThreadReport, @@ -250,11 +249,7 @@ function ReportFetchHandler() { }, [reportID, isFocused, isInSidePanel]); useEffect(() => { - const interactionTask = InteractionManager.runAfterInteractions(() => { - setShouldShowComposeInput(true); - }); return () => { - interactionTask.cancel(); onUnmount(); }; }, []); diff --git a/src/pages/inbox/ReportNavigateAwayHandler.tsx b/src/pages/inbox/ReportNavigateAwayHandler.tsx index a3d5e2a60f6c..b76d6e84a8c7 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -14,7 +14,6 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import {isDeletedParentAction} from '@libs/ReportActionsUtils'; import {isAdminRoom, isAnnounceRoom, isGroupChat, isMoneyRequest, isMoneyRequestReport, isMoneyRequestReportPendingDeletion, isPolicyExpenseChat} from '@libs/ReportUtils'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@navigation/types'; -import {setShouldShowComposeInput} from '@userActions/Composer'; import {navigateToConciergeChat} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -164,18 +163,7 @@ function ReportNavigateAwayHandler() { (prevDeletedParentAction && !deletedParentAction) ) { navigateAwayFromReport(prevOnyxReportID, prevReport?.parentReportID); - return; } - - // If you already have a report open and are deeplinking to a new report on native, - // the ReportScreen never actually unmounts and the reportID in the route also doesn't change. - // Therefore, we need to compare if the existing reportID is the same as the one in the route - // before deciding that we shouldn't call OpenReport. - if (reportIDFromRoute === lastReportIDFromRoute && (!onyxReportID || onyxReportID === reportIDFromRoute)) { - return; - } - - setShouldShowComposeInput(true); }, [ report, prevReport?.reportID, diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index bd1df7d91dc1..684f69b66284 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -16,6 +16,7 @@ import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; import {removeFailedReport} from '@libs/actions/Report'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; +import {isMoneyRequestReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; @@ -28,7 +29,9 @@ import useFlushDeferredWriteOnFocus from './hooks/useFlushDeferredWriteOnFocus'; import LinkedActionNotFoundGuard from './LinkedActionNotFoundGuard'; import ReactionListWrapper from './ReactionListWrapper'; import ReportActionComposePlaceholder from './report/ReportActionCompose/ReportActionComposePlaceholder'; +import {ReportActionEditMessageContextProvider, ReportScreenEditMessageProviderWithTransactionThread} from './report/ReportActionEditMessageContext'; import ReportFooter from './report/ReportFooter'; +import useClearReportActionDraftsOnReportChange from './report/useClearReportActionDraftsOnReportChange'; import ReportActionsList from './ReportActionsList'; import ReportDragAndDropProvider from './ReportDragAndDropProvider'; import ReportFetchHandler from './ReportFetchHandler'; @@ -43,6 +46,26 @@ import WideRHPReceiptPanel from './WideRHPReceiptPanel'; type ReportScreenProps = ReportScreenNavigationProps; +type ReportScreenEditMessageProviderProps = { + /** The report ID */ + reportID: string | undefined; + /** The children */ + children: React.ReactNode; +}; + +/** Money-request screens need transaction-thread derivation; others use the lighter provider path. */ +function ReportScreenEditMessageProvider({reportID, children}: ReportScreenEditMessageProviderProps) { + const [shouldDeriveMoneyRequestTransactionThread] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + selector: (reportEntry) => !!reportEntry && isMoneyRequestReport(reportEntry), + }); + + if (shouldDeriveMoneyRequestTransactionThread !== true) { + return {children}; + } + + return {children}; +} + function ReportScreen({route, navigation}: ReportScreenProps) { const styles = useThemeStyles(); const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); @@ -78,64 +101,68 @@ function ReportScreen({route, navigation}: ReportScreenProps) { }); }; + useClearReportActionDraftsOnReportChange(reportIDFromRoute); + return ( - - - - - {!shouldDeferNonEssentials && ( - <> - - - - - - )} - - - - {!shouldDeferNonEssentials && } - - - {!shouldDeferNonEssentials && } - - - - {!shouldDeferNonEssentials && } - - - - - {shouldDeferNonEssentials ? : } - - - - - - - - - - - - - + + + + + + {!shouldDeferNonEssentials && ( + <> + + + + + + )} + + + + {!shouldDeferNonEssentials && } + + + {!shouldDeferNonEssentials && } + + + + {!shouldDeferNonEssentials && } + + + + + {shouldDeferNonEssentials ? : } + + + + + + + + + + + + + + ); } diff --git a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx index 90fd9f7bf94c..cdc54d5ef3e6 100755 --- a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -84,9 +84,6 @@ type BaseReportActionContextMenuProps = { /** The copy selection. */ selection?: string; - /** Draft message - if this is set the comment is in 'edit' mode */ - draftMessage?: string; - /** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */ type?: ContextMenuType; @@ -120,7 +117,6 @@ function BaseReportActionContextMenu({ isVisible = false, isThreadReportParentAction = false, selection = '', - draftMessage = '', reportActionID, reportID, originalReportID, @@ -355,7 +351,6 @@ function BaseReportActionContextMenu({ }, reportAction: { reportActionID: reportAction?.reportActionID, - draftMessage, isThreadReportParentAction, }, callbacks: { @@ -391,7 +386,6 @@ function BaseReportActionContextMenu({ reportID, originalReportID, report, - draftMessage, selection, close: () => setShouldKeepOpen(false), transitionActionSheetState, diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 6a99cb8c13f9..6fe6b097f641 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -187,7 +187,6 @@ import { import {getTaskCreatedMessage, getTaskReportActionMessage} from '@libs/TaskUtils'; import {setDownload} from '@userActions/Download'; import { - deleteReportActionDraft, explain, markCommentAsUnread, navigateToAndOpenChildReport, @@ -276,7 +275,6 @@ type ContextMenuActionPayload = { currentUserAccountID: number; report: OnyxEntry; policy?: OnyxEntry; - draftMessage: string; selection: string; close: () => void; transitionActionSheetState: (params: {type: string; payload?: Record}) => void; @@ -315,7 +313,7 @@ type ContextMenuActionPayload = { delegateAccountID: number | undefined; }; -type OnPress = (closePopover: boolean, payload: ContextMenuActionPayload, selection?: string, reportID?: string, draftMessage?: string) => void; +type OnPress = (closePopover: boolean, payload: ContextMenuActionPayload, selection?: string, reportID?: string) => void; type RenderContent = (closePopover: boolean, payload: ContextMenuActionPayload) => React.ReactElement; @@ -554,7 +552,7 @@ const ContextMenuActions: ContextMenuAction[] = [ (canEditReportAction(reportAction, iouTransaction) || canEditReportAction(moneyRequestAction, iouTransaction)) && !isArchivedRoom && !isChronosReport, - onPress: (closePopover, {reportID, reportAction, draftMessage, moneyRequestAction, introSelected, betas}) => { + onPress: (closePopover, {reportID, originalReportID, reportAction, moneyRequestAction, introSelected, betas}) => { if (isMoneyRequestAction(reportAction) || isMoneyRequestAction(moneyRequestAction)) { const editExpense = () => { const childReportID = reportAction?.childReportID; @@ -569,11 +567,7 @@ const ContextMenuActions: ContextMenuAction[] = [ return; } const editAction = () => { - if (!draftMessage) { - saveReportActionDraft(reportID, reportAction, Parser.htmlToMarkdown(getActionHtml(reportAction))); - } else { - deleteReportActionDraft(reportID, reportAction); - } + saveReportActionDraft(originalReportID ?? reportID, reportAction, Parser.htmlToMarkdown(getActionHtml(reportAction))); }; if (closePopover) { diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index b4b6a180062b..1e476645c3d6 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -55,7 +55,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro const reportActionIDRef = useRef(undefined); const originalReportIDRef = useRef(undefined); const selectionRef = useRef(''); - const reportActionDraftMessageRef = useRef(undefined); const isReportArchived = useReportIsArchived(reportIDRef.current); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDRef.current}`); const reportActionsRef = useRef(reportActions); @@ -173,7 +172,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro * @param reportID - Active Report Id * @param reportActionID - ReportAction for ContextMenu * @param originalReportID - The current Report Id of the reportAction - * @param draftMessage - ReportAction draft message * @param [onShow] - Run a callback when Menu is shown * @param [onHide] - Run a callback when Menu is hidden */ @@ -199,7 +197,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro } const {reportID, originalReportID} = currentReport; - const {reportActionID, draftMessage, isThreadReportParentAction: isThreadReportParentActionParam = false} = reportAction; + const {reportActionID, isThreadReportParentAction: isThreadReportParentActionParam = false} = reportAction; const {onShow = () => {}, onHide = () => {}, setIsEmojiPickerActive = () => {}} = callbacks; setIsContextMenuOpening(true); setIsWithoutOverlay(withoutOverlay); @@ -245,7 +243,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro originalReportIDRef.current = originalReportID || undefined; selectionRef.current = selection; setIsPopoverVisible(true); - reportActionDraftMessageRef.current = draftMessage; setIsThreadReportParentAction(isThreadReportParentActionParam); setShouldSwitchPositionIfOverflow(isOverflowMenu); }); @@ -289,7 +286,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro */ const performHide = () => { selectionRef.current = ''; - reportActionDraftMessageRef.current = undefined; setIsPopoverVisible(false); transitionActionSheetState({ @@ -490,7 +486,6 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro type={typeRef.current} reportID={reportIDRef.current} reportActionID={reportActionIDRef.current} - draftMessage={reportActionDraftMessageRef.current} selection={selectionRef.current} isThreadReportParentAction={isThreadReportParentAction} anchor={contextMenuTargetNode} diff --git a/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts index 83c1ee3dcc53..8ea3fc750f17 100644 --- a/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts @@ -28,7 +28,6 @@ type ShowContextMenuParams = { }; reportAction?: { reportActionID?: string; - draftMessage?: string; isThreadReportParentAction?: boolean; }; callbacks?: { @@ -117,7 +116,6 @@ function hideContextMenu(shouldDelay?: boolean, onHideCallback = () => {}, param * @param reportID - Active Report Id * @param reportActionID - ReportActionID for ContextMenu * @param originalReportID - The current Report Id of the reportAction - * @param draftMessage - ReportAction draft message * @param [onShow=() => {}] - Run a callback when Menu is shown * @param [onHide=() => {}] - Run a callback when Menu is hidden */ diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index d348722bc010..570f141bd320 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -292,6 +292,7 @@ function PureReportActionItem({ const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; const [isReportActionActive, setIsReportActionActive] = useState(!!isReportActionLinked); const isReportArchived = useReportIsArchived(reportID); + const isEditingInline = !shouldUseNarrowLayout && draftMessage !== undefined; const isHarvestCreatedExpenseReport = isHarvestCreatedExpenseReportUtils(reportNameValuePairsOrigin, reportNameValuePairsOriginalID); @@ -455,6 +456,13 @@ function PureReportActionItem({ const disabledActions = useMemo(() => (!canWriteInReport(report) ? RestrictedReadOnlyContextMenuActions : []), [report]); + const hasActionErrors = !isEmptyValueObject(action.errors); + // Receipt upload errors should still allow the context menu so the user can access "Delete expense" + const hasOnlyReceiptErrors = hasActionErrors && Object.values(action.errors ?? {}).every((error) => error === null || isReceiptError(error)); + const isContextMenuDisabled = useMemo(() => { + return draftMessage !== undefined || (hasActionErrors && !hasOnlyReceiptErrors) || !shouldDisplayContextMenuValue; + }, [draftMessage, hasActionErrors, hasOnlyReceiptErrors, shouldDisplayContextMenuValue]); + /** * Show the ReportActionContextMenu modal popover. * @@ -462,11 +470,8 @@ function PureReportActionItem({ */ const showPopover = useCallback( (event: GestureResponderEvent | MouseEvent) => { - const hasActionErrors = !isEmptyValueObject(action.errors); - // Receipt upload errors should still allow the context menu so the user can access "Delete expense" - const hasOnlyReceiptErrors = hasActionErrors && Object.values(action.errors ?? {}).every((error) => error === null || isReceiptError(error)); - // Block menu on the message being Edited or if the report action item has errors (except receipt upload errors, to allow Delete) - if (draftMessage !== undefined || (hasActionErrors && !hasOnlyReceiptErrors) || !shouldDisplayContextMenuValue) { + // Block menu on the message being Edited or if the report action item has errors + if (isContextMenuDisabled) { return; } @@ -484,7 +489,6 @@ function PureReportActionItem({ }, reportAction: { reportActionID: action.reportActionID, - draftMessage, isThreadReportParentAction, }, callbacks: { @@ -497,15 +501,13 @@ function PureReportActionItem({ }); }, [ - draftMessage, - action.errors, action.reportActionID, reportID, toggleContextMenuFromActiveReportAction, originalReportID, - shouldDisplayContextMenuValue, disabledActions, handleShowContextMenu, + isContextMenuDisabled, isThreadReportParentAction, ], ); @@ -535,10 +537,9 @@ function PureReportActionItem({ * Get the content of ReportActionItem * @param hovered whether the ReportActionItem is hovered * @param isWhisper whether the report action is a whisper - * @param hasErrors whether the report action has any errors * @returns child component(s) */ - const renderItemContent = (hovered = false, isWhisper = false, hasErrors = false): React.JSX.Element => { + const renderItemContent = (hovered = false, isWhisper = false): React.JSX.Element => { let children; const moneyRequestOriginalMessage = isMoneyRequestAction(action) ? getOriginalMessage(action) : undefined; const moneyRequestActionType = moneyRequestOriginalMessage?.type; @@ -875,7 +876,7 @@ function PureReportActionItem({ ?.split(',') .map((accountID) => Number(accountID)) .filter((accountID): accountID is number => typeof accountID === 'number') ?? []; - const draftMessageRightAlign = draftMessage !== undefined ? styles.chatItemReactionsDraftRight : {}; + const draftMessageRightAlign = isEditingInline ? styles.chatItemReactionsDraftRight : {}; const itemContent = ( <> @@ -890,7 +891,7 @@ function PureReportActionItem({ @@ -920,18 +921,17 @@ function PureReportActionItem({ * Get ReportActionItem with a proper wrapper * @param hovered whether the ReportActionItem is hovered * @param isWhisper whether the ReportActionItem is a whisper - * @param hasErrors whether the report action has any errors * @returns report action item */ - const renderReportActionItem = (hovered: boolean, isWhisper: boolean, hasErrors: boolean): React.JSX.Element => { - const content = renderItemContent(hovered || isContextMenuActive || isEmojiPickerActive, isWhisper, hasErrors); + const renderReportActionItem = (hovered: boolean, isWhisper: boolean): React.JSX.Element => { + const content = renderItemContent(hovered || isContextMenuActive || isEmojiPickerActive, isWhisper); if (isEmptyHTML(content) || (!shouldRenderViewBasedOnAction && !isClosedExpenseReportWithNoExpenses)) { return emptyHTML; } - if (draftMessage !== undefined) { + if (!shouldUseNarrowLayout && draftMessage !== undefined) { return {content}; } @@ -939,7 +939,7 @@ function PureReportActionItem({ return ( error === null || isReceiptError(error)); const whisperedTo = getWhisperedTo(action); const iouReportID = isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUReportID ? getOriginalMessage(action)?.IOUReportID?.toString() : undefined; @@ -1038,7 +1035,7 @@ function PureReportActionItem({ onPressIn={() => shouldUseNarrowLayout && canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} onSecondaryInteraction={showPopover} - preventDefaultContextMenu={draftMessage === undefined && (!hasErrors || hasOnlyReceiptErrors)} + preventDefaultContextMenu={!isContextMenuDisabled} withoutFocusOnSecondaryInteraction accessibilityLabel={accessibilityLabel} accessibilityHint={translate('accessibilityHints.chatMessage')} @@ -1059,7 +1056,7 @@ function PureReportActionItem({ {(hovered) => ( {shouldDisplayNewMarker && (!shouldUseThreadDividerLine || !isFirstVisibleReportAction) && } - {shouldDisplayContextMenuValue && (hovered || !!isEmojiPickerActive || isContextMenuActive) && draftMessage === undefined && !hasErrors && ( + {shouldDisplayContextMenuValue && (hovered || !!isEmojiPickerActive || isContextMenuActive) && draftMessage === undefined && !hasActionErrors && ( @@ -1099,7 +1095,7 @@ function PureReportActionItem({ onPress={onPress} > {isWhisper && } - {renderReportActionItem(!!hovered || !!isReportActionLinked, isWhisper, hasErrors)} + {renderReportActionItem(!!hovered || !!isReportActionLinked, isWhisper)} diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 120fdf89f811..86f786a957b8 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -50,6 +50,7 @@ import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft' import type {AnchorPosition} from '@src/styles'; import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; +import ExpandCollapseButton from './ExpandCollapseButton'; type MoneyRequestOptions = Record< Exclude, @@ -109,6 +110,8 @@ type AttachmentPickerWithMenuItemsProps = { reportParticipantIDs?: number[]; shouldDisableAttachmentItem?: boolean; + + testID?: string; }; /** @@ -134,6 +137,7 @@ function AttachmentPickerWithMenuItems({ actionButtonRef, raiseIsScrollLikelyLayoutTriggered, shouldDisableAttachmentItem, + testID, }: AttachmentPickerWithMenuItemsProps) { const icons = useMemoizedLazyExpensifyIcons([ 'Cash', @@ -445,7 +449,10 @@ function AttachmentPickerWithMenuItems({ ]; return ( <> - + @@ -475,61 +482,15 @@ function AttachmentPickerWithMenuItems({ - {(isFullComposerAvailable || isComposerFullSize) && ( - - {isComposerFullSize ? ( - - { - e?.preventDefault(); - raiseIsScrollLikelyLayoutTriggered(); - setIsComposerFullSize(reportID, false); - }} - // Keep focus on the composer when Collapse button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={disabled} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.collapse')} - sentryLabel={CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_COLLAPSE_BUTTON} - > - - - - ) : ( - - { - e?.preventDefault(); - raiseIsScrollLikelyLayoutTriggered(); - setIsComposerFullSize(reportID, true); - }} - // Keep focus on the composer when Expand button is clicked. - onMouseDown={(e) => e.preventDefault()} - style={styles.composerSizeButton} - disabled={disabled} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('reportActionCompose.expand')} - sentryLabel={CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_EXPAND_BUTTON} - > - - - - )} - - )} + pickAttachments({files})} reportID={reportID} report={report} @@ -58,7 +60,7 @@ function ComposerActionMenu({reportID}: ComposerActionMenuProps) { if (!shouldFocusComposerOnScreenFocus) { return; } - focus(); + composerRef.current?.focus(true); }} actionButtonRef={actionButtonRef} shouldDisableAttachmentItem={!!exceededMaxLength} diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx index f374b233d08d..568c90b2a619 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx @@ -13,21 +13,23 @@ type ComposerBoxProps = { function ComposerBox({reportID, children}: ComposerBoxProps) { const styles = useThemeStyles(); const {isFocused} = useComposerState(); - const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); + const {isExceedingMaxLength, isBlockedFromConcierge} = useComposerSendState(); const {containerRef} = useComposerMeta(); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; + const containerStyles = [ + shouldUseFocusedColor ? styles.chatItemComposeBoxFocusedColor : styles.chatItemComposeBoxColor, + styles.flexRow, + styles.chatItemComposeBox, + isComposerFullSize && styles.chatItemFullComposeBox, + isExceedingMaxLength && styles.borderColorDanger, + ]; + return ( {children} diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 677c11432a2c..d871c536c0df 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -2,9 +2,14 @@ import type {RefObject} from 'react'; import {createContext, useContext} from 'react'; import type {BlurEvent, TextInputSelectionChangeEvent, View} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; +import type {TextSelection} from '@components/Composer/types'; import type {Mention} from '@components/MentionSuggestions'; +import type {ReportActionEditMessageState} from '@pages/inbox/report/ReportActionEditMessageContext'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {ComposerWithSuggestionsRef} from './ComposerWithSuggestions'; +import type useDebouncedCommentMaxLengthValidation from './useDebouncedCommentMaxLengthValidation'; type SuggestionsRef = { resetSuggestions: () => void; @@ -26,12 +31,27 @@ type ComposerState = { isFullComposerAvailable: boolean; }; +type ComposerEditState = { + editingState: ReportActionEditMessageState; + isEditingInComposer: boolean; + editingReportID: string | null; + editingReportActionID: string | null; + editingReportAction: ReportAction | null; + editingMessage: string | null; + draftComment: string | undefined; + effectiveDraft: string | null | undefined; + currentEditMessageSelection: TextSelection | null; + didResetComposerHeightWhileEditing: boolean; +}; + // Warm — changes based on content + policy type ComposerSendState = { isSendDisabled: boolean; + debouncedCommentMaxLengthValidation: ReturnType['debouncedCommentMaxLengthValidation'] | null; + isExceedingMaxLength: boolean; exceededMaxLength: number | null; - hasExceededMaxTaskTitleLength: boolean; isBlockedFromConcierge: boolean; + isTaskTitle: boolean; }; // Frozen — stable references, never changes after mount @@ -39,8 +59,7 @@ type ComposerActions = { setText: (v: string) => void; setMenuVisibility: (v: boolean) => void; setIsFullComposerAvailable: (v: boolean) => void; - setComposerRef: (ref: ComposerRef | null) => void; - focus: () => void; + setComposerRef: (ref: ComposerWithSuggestionsRef | null) => void; onBlur: (event: BlurEvent) => void; onFocus: () => void; onAddActionPressed: () => void; @@ -49,16 +68,16 @@ type ComposerActions = { clearComposer: () => void; }; -// Infrequent — changes only when send logic changes -type ComposerSendActions = { - handleSendMessage: () => void; - onValueChange: (value: string) => void; +type ComposerEditActions = { + publishDraft: (draftMessage: string) => void; + deleteDraft: () => void; + setDidResetComposerHeightWhileEditing: (v: boolean) => void; }; // Frozen — stable refs, set once type ComposerMeta = { containerRef: RefObject; - composerRef: RefObject; + composerRef: RefObject; suggestionsRef: RefObject; actionButtonRef: RefObject; isNextModalWillOpenRef: RefObject; @@ -78,10 +97,27 @@ const ComposerStateContext = createContext(defaultState); const defaultSendState: ComposerSendState = { isSendDisabled: true, + debouncedCommentMaxLengthValidation: null, + isExceedingMaxLength: false, exceededMaxLength: null, - hasExceededMaxTaskTitleLength: false, isBlockedFromConcierge: false, + isTaskTitle: false, +}; + +const defaultEditState: ComposerEditState = { + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF, + isEditingInComposer: false, + editingReportID: null, + editingReportActionID: null, + editingReportAction: null, + editingMessage: null, + draftComment: undefined, + effectiveDraft: undefined, + currentEditMessageSelection: null, + didResetComposerHeightWhileEditing: false, }; +const ComposerEditStateContext = createContext(defaultEditState); + const ComposerSendStateContext = createContext(defaultSendState); const defaultActions: ComposerActions = { @@ -89,7 +125,6 @@ const defaultActions: ComposerActions = { setMenuVisibility: noop, setIsFullComposerAvailable: noop, setComposerRef: noop, - focus: noop, onBlur: noop, onFocus: noop, onAddActionPressed: noop, @@ -99,11 +134,12 @@ const defaultActions: ComposerActions = { }; const ComposerActionsContext = createContext(defaultActions); -const defaultSendActions: ComposerSendActions = { - handleSendMessage: noop, - onValueChange: noop, +const defaultEditActions: ComposerEditActions = { + publishDraft: noop, + deleteDraft: noop, + setDidResetComposerHeightWhileEditing: noop, }; -const ComposerSendActionsContext = createContext(defaultSendActions); +const ComposerEditActionsContext = createContext(defaultEditActions); const ComposerMetaContext = createContext(null); @@ -115,6 +151,10 @@ function useComposerState() { return useContext(ComposerStateContext); } +function useComposerEditState() { + return useContext(ComposerEditStateContext); +} + function useComposerSendState() { return useContext(ComposerSendStateContext); } @@ -123,8 +163,8 @@ function useComposerActions() { return useContext(ComposerActionsContext); } -function useComposerSendActions() { - return useContext(ComposerSendActionsContext); +function useComposerEditActions() { + return useContext(ComposerEditActionsContext); } function useComposerMeta() { @@ -138,15 +178,17 @@ function useComposerMeta() { export { ComposerTextContext, ComposerStateContext, + ComposerEditStateContext, ComposerSendStateContext, ComposerActionsContext, - ComposerSendActionsContext, + ComposerEditActionsContext, ComposerMetaContext, useComposerText, useComposerState, + useComposerEditState, useComposerSendState, useComposerActions, - useComposerSendActions, + useComposerEditActions, useComposerMeta, }; -export type {SuggestionsRef}; +export type {SuggestionsRef, ComposerEditState, ComposerActions}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx new file mode 100644 index 000000000000..77f8f58dfae0 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEditingButtons.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import {useComposerEditActions} from './ComposerContext'; +import ComposerExpandCollapseButton from './ComposerExpandCollapseButton'; +import MessageEditCancelButton from './MessageEditCancelButton'; + +type ComposerEditingButtonsProps = { + /** The report ID */ + reportID: string; +}; + +function ComposerEditingButtons({reportID}: ComposerEditingButtonsProps) { + const styles = useThemeStyles(); + + const {deleteDraft} = useComposerEditActions(); + + const editingButtonsContainerStyles = [ + styles.dFlex, + styles.alignItemsCenter, + styles.flexWrap, + styles.justifyContentCenter, + {paddingVertical: styles.composerSizeButton.marginHorizontal}, + ]; + const expandCollapseComposerButtonStyles = [styles.flexGrow1, styles.flexShrink0, {marginRight: styles.composerSizeButton.marginHorizontal}]; + + return ( + + + + + ); +} + +export default ComposerEditingButtons; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx index 59199bdf3a9d..99c26549cd59 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx @@ -6,7 +6,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; import CONST from '@src/CONST'; -import {useComposerActions, useComposerMeta, useComposerSendState} from './ComposerContext'; +import {useComposerMeta, useComposerSendState} from './ComposerContext'; type ComposerEmojiPickerProps = { reportID: string; @@ -16,7 +16,6 @@ function ComposerEmojiPicker({reportID}: ComposerEmojiPickerProps) { const styles = useThemeStyles(); const {isMediumScreenWidth} = useResponsiveLayout(); - const {focus} = useComposerActions(); const {composerRef} = useComposerMeta(); const {isBlockedFromConcierge} = useComposerSendState(); @@ -51,7 +50,7 @@ function ComposerEmojiPicker({reportID}: ComposerEmojiPickerProps) { if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { return; } - focus(); + composerRef.current?.focus(true); }} onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} emojiPickerID={reportID} diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerExceededLength.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerExceededLength.tsx index c7e35ab69638..0e99abb608c0 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerExceededLength.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerExceededLength.tsx @@ -3,14 +3,14 @@ import ExceededCommentLength from '@components/ExceededCommentLength'; import {useComposerSendState} from './ComposerContext'; function ComposerExceededLength() { - const {exceededMaxLength, hasExceededMaxTaskTitleLength} = useComposerSendState(); + const {exceededMaxLength, isTaskTitle} = useComposerSendState(); if (!exceededMaxLength) { return null; } return ( ); } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerExpandCollapseButton.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerExpandCollapseButton.tsx new file mode 100644 index 000000000000..f2b269b08a6b --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerExpandCollapseButton.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type {ViewProps} from 'react-native'; +import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; +import useOnyx from '@hooks/useOnyx'; +import {setIsComposerFullSize} from '@userActions/Report'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {useComposerSendState, useComposerState} from './ComposerContext'; +import ExpandCollapseButton from './ExpandCollapseButton'; + +type ComposerExpandCollapseButtonProps = ViewProps & { + /** The report ID */ + reportID: string; +}; + +function ComposerExpandCollapseButton({reportID, ...restProps}: ComposerExpandCollapseButtonProps) { + const {isBlockedFromConcierge} = useComposerSendState(); + const {raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const {isFullComposerAvailable} = useComposerState(); + + return ( + + ); +} + +export default ComposerExpandCollapseButton; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx index 55d199f02eba..1b01a369687f 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -5,6 +5,7 @@ import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTrig import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import {setIsComposerFullSize} from '@libs/actions/Report'; import FS from '@libs/Fullstory'; import { canUserPerformWriteAction as canUserPerformWriteActionReportUtils, @@ -17,7 +18,7 @@ import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {useComposerActions, useComposerMeta, useComposerSendActions, useComposerSendState, useComposerState} from './ComposerContext'; +import {useComposerActions, useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; import ComposerWithSuggestions from './ComposerWithSuggestions'; import useAttachmentPicker from './useAttachmentPicker'; import useComposerSubmit from './useComposerSubmit'; @@ -36,19 +37,24 @@ function getRandomPlaceholder(translate: LocalizedTranslate): string { function ComposerInput({reportID}: ComposerInputProps) { const {translate, preferredLocale} = useLocalize(); const {isMenuVisible} = useComposerState(); - const {isBlockedFromConcierge} = useComposerSendState(); + const {isBlockedFromConcierge, debouncedCommentMaxLengthValidation} = useComposerSendState(); const {setIsFullComposerAvailable, onBlur, onFocus, setComposerRef} = useComposerActions(); - const {handleSendMessage, onValueChange} = useComposerSendActions(); const {containerRef, suggestionsRef, isNextModalWillOpenRef} = useComposerMeta(); - const submitForm = useComposerSubmit(reportID); + const {submitDraftAndClearComposer, validateAndSubmitDraft} = useComposerSubmit(reportID); const {pickAttachments, PDFValidationComponent, ErrorModal} = useAttachmentPicker(reportID); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); - const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); + const onValueChange = (v: string) => { + if (v.length === 0 && isComposerFullSize) { + setIsComposerFullSize(reportID, false); + } + debouncedCommentMaxLengthValidation?.(v); + }; + const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { containerRef.current?.measureInWindow(callback); }; @@ -89,10 +95,9 @@ function ComposerInput({reportID}: ComposerInputProps) { isComposerFullSize={isComposerFullSize} setIsFullComposerAvailable={setIsFullComposerAvailable} onPasteFile={(files) => pickAttachments({files})} - onClear={submitForm} + onClear={validateAndSubmitDraft} disabled={isBlockedFromConcierge || isEmojiPickerVisible()} - onEnterKeyPress={handleSendMessage} - shouldShowComposeInput={shouldShowComposeInput} + onEnterKeyPress={submitDraftAndClearComposer} onFocus={onFocus} onBlur={onBlur} measureParentContainer={measureContainer} diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index fe22557a0f4c..f4e6b04329d7 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -1,22 +1,30 @@ -import lodashDebounce from 'lodash/debounce'; import React, {useRef, useState} from 'react'; import type {View} from 'react-native'; -import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; -import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; -import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; import useOnyx from '@hooks/useOnyx'; +import useOriginalReportID from '@hooks/useOriginalReportID'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {chatIncludesConcierge} from '@libs/ReportUtils'; -import {setIsComposerFullSize} from '@userActions/Report'; +import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {FileObject} from '@src/types/utils/Attachment'; -import {ComposerActionsContext, ComposerMetaContext, ComposerSendActionsContext, ComposerSendStateContext, ComposerStateContext, ComposerTextContext} from './ComposerContext'; +import { + ComposerActionsContext, + ComposerEditActionsContext, + ComposerEditStateContext, + ComposerMetaContext, + ComposerSendStateContext, + ComposerStateContext, + ComposerTextContext, +} from './ComposerContext'; import type {SuggestionsRef} from './ComposerContext'; -import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {ComposerWithSuggestionsRef} from './ComposerWithSuggestions'; import useComposerFocus from './useComposerFocus'; +import useDebouncedCommentMaxLengthValidation from './useDebouncedCommentMaxLengthValidation'; +import useEditMessage from './useEditMessage'; const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); @@ -26,64 +34,61 @@ type ComposerProviderProps = { }; function ComposerProvider({children, reportID}: ComposerProviderProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); - const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); const [initialModalState] = useOnyx(ONYXKEYS.MODAL); const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; + const initialFocused = shouldFocusComposerOnScreenFocus && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; - const initialFocused = shouldFocusComposerOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); + const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); + const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); const [isMenuVisible, setMenuVisibility] = useState(false); - const [text, setText] = useState(() => { return draftComment ?? ''; }); - const isEmpty = !text || !!text.match(CONST.REGEX.EMPTY_COMMENT); - - const includesConcierge = chatIncludesConcierge({participants: report?.participants}); - const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); - const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; + const containerRef = useRef(null); + const suggestionsRef = useRef(null); + const composerRef = useRef(null); + const actionButtonRef = useRef(null); + const attachmentFileRef = useRef(null); - const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength(); - const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength(); + const {editingState, editingReportID, editingReportActionID, editingReportAction, editingMessage, currentEditMessageSelection} = useReportActionActiveEdit(); - let exceededMaxLength: number | null = null; - if (hasExceededMaxTaskTitleLength) { - exceededMaxLength = CONST.TITLE_CHARACTER_LIMIT; - } else if (hasExceededMaxCommentLength) { - exceededMaxLength = CONST.MAX_COMMENT_LENGTH; - } + const [didResetComposerHeightWhileEditing, setDidResetComposerHeightWhileEditing] = useState(false); - const isSendDisabled = isEmpty || isBlockedFromConcierge || !!exceededMaxLength; + const isEditingInComposer = shouldUseNarrowLayout && editingState !== 'off' && !didResetComposerHeightWhileEditing; + const effectiveDraft = isEditingInComposer ? editingMessage : draftComment; - const validateMaxLength = (v: string) => { - const taskCommentMatch = v?.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (taskCommentMatch) { - const title = taskCommentMatch?.[3] ? taskCommentMatch[3].trim().replaceAll('\n', ' ') : ''; - setHasExceededMaxCommentLength(false); - return validateTaskTitleMaxLength(title); - } - setHasExceededMaxTitleLength(false); - return validateCommentMaxLength(v, {reportID}); - }; + const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({ + reportID, + isEditing: !!editingReportAction, + }); - const debouncedValidate = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}); + const originalReportID = useOriginalReportID(editingReportID ?? undefined, editingReportAction); - const containerRef = useRef(null); - const suggestionsRef = useRef(null); - const composerRef = useRef(null); - const actionButtonRef = useRef(null); - const attachmentFileRef = useRef(null); + const {publishDraft, deleteDraft} = useEditMessage({ + reportID: editingReportID ?? undefined, + originalReportID, + reportAction: editingReportAction, + shouldScrollToLastMessage: false, + debouncedCommentMaxLengthValidation, + composerRef, + }); - const composerRefShared = useSharedValue>({}); + const isDraftCommentEmpty = !text || !!text.match(CONST.REGEX.EMPTY_COMMENT); + const isSubmittingDraftCommentDisabled = isBlockedFromConcierge || isExceedingMaxLength || isDraftCommentEmpty; + const isSendDisabled = !isEditingInComposer && isSubmittingDraftCommentDisabled; - const {isFocused, onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ + const {isFocused, onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ composerRef, suggestionsRef, actionButtonRef, @@ -98,40 +103,8 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { scheduleOnUI(clearWorklet); }; - const handleSendMessage = () => { - if (isSendDisabled || !debouncedValidate.flush()) { - return; - } - - composerRef.current?.resetHeight(); - if (isComposerFullSize) { - setIsComposerFullSize(reportID, false); - } - - scheduleOnUI(() => { - const {clearWorklet} = composerRefShared.get(); - - if (!clearWorklet) { - throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); - } - - clearWorklet?.(); - }); - }; - - const setComposerRef = (ref: ComposerRef | null) => { + const setComposerRef = (ref: ComposerWithSuggestionsRef | null) => { composerRef.current = ref; - composerRefShared.set({ - clearWorklet: ref?.clearWorklet, - }); - }; - - const onValueChange = (v: string) => { - setText(v); - if (v.length === 0 && isComposerFullSize) { - setIsComposerFullSize(reportID, false); - } - debouncedValidate(v); }; const composerState = { @@ -140,11 +113,26 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { isFullComposerAvailable, }; + const composerEditState = { + editingState, + isEditingInComposer, + editingReportID, + editingReportActionID, + editingReportAction, + editingMessage, + draftComment, + effectiveDraft, + currentEditMessageSelection, + didResetComposerHeightWhileEditing, + }; + const composerSendState = { isSendDisabled, + debouncedCommentMaxLengthValidation, + isExceedingMaxLength, exceededMaxLength, - hasExceededMaxTaskTitleLength, isBlockedFromConcierge, + isTaskTitle, }; const composerActions = { @@ -152,7 +140,6 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { setMenuVisibility, setIsFullComposerAvailable, setComposerRef, - focus, onBlur, onFocus, onAddActionPressed, @@ -161,9 +148,10 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { clearComposer, }; - const composerSendActions = { - handleSendMessage, - onValueChange, + const composerEditActions = { + publishDraft, + deleteDraft, + setDidResetComposerHeightWhileEditing, }; const composerMeta = { @@ -179,11 +167,13 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { - - - {children} - - + + + + {children} + + + diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx index 111420516795..79aab7007d88 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx @@ -1,16 +1,69 @@ import React from 'react'; -import {useComposerSendActions, useComposerSendState} from './ComposerContext'; -import SendButton from './SendButton'; +import {View} from 'react-native'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import {useComposerEditState, useComposerSendState} from './ComposerContext'; +import SubmitDraftButton from './SubmitDraftButton'; +import useComposerSubmit from './useComposerSubmit'; -function ComposerSendButton() { +function ComposerSendButton({reportID}: {reportID: string}) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Send', 'Checkmark']); + + const {isEditingInComposer} = useComposerEditState(); const {isSendDisabled} = useComposerSendState(); - const {handleSendMessage} = useComposerSendActions(); + const {submitDraftAndClearComposer} = useComposerSubmit(reportID); + + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to manage GestureDetector correctly + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const Tap = Gesture.Tap() + .enabled(!isSendDisabled) + .onEnd(() => { + submitDraftAndClearComposer(); + }) + .runOnJS(true); + + const label = translate(isEditingInComposer ? 'common.saveChanges' : 'common.send'); + const icon = isEditingInComposer ? icons.Checkmark : icons.Send; return ( - + e.preventDefault()} + > + + + + + + ); } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx similarity index 77% rename from src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx rename to src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 57955ea50a7a..a55fbb3252cc 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -1,13 +1,12 @@ import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; import type {Ref, RefObject} from 'react'; -import React, {memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import type { BlurEvent, LayoutChangeEvent, MeasureInWindowOnSuccessCallback, NativeMethods, - TextInput, TextInputContentSizeChangeEvent, TextInputKeyPressEvent, TextInputScrollEvent, @@ -15,12 +14,11 @@ import type { // eslint-disable-next-line no-restricted-imports import {DeviceEventEmitter, InteractionManager, NativeModules, StyleSheet, View} from 'react-native'; import {useFocusedInputHandler} from 'react-native-keyboard-controller'; -import type {OnyxEntry} from 'react-native-onyx'; import {useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; import Composer from '@components/Composer'; -import type {CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; +import type {ComposerRef, CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; import {useWideRHPState} from '@components/WideRHPContextProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsInSidePanel from '@hooks/useIsInSidePanel'; @@ -40,8 +38,8 @@ import {canSkipTriggerHotkeys, findCommonSuffixLength, insertText, insertWhiteSp import convertToLTRForComposer from '@libs/convertToLTRForComposer'; import {containsOnlyEmojis, extractEmojis, getAddedEmojis, getTextVSCursorOffset, insertTextVSBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; +import type {Selection} from '@libs/focusComposerWithDelay/types'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; -import getPlatform from '@libs/getPlatform'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; import {detectAndRewritePaste} from '@libs/MarkdownLinkHelpers'; import Parser from '@libs/Parser'; @@ -49,13 +47,9 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; -import {useComposerActions, useComposerText} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; -import getCursorPosition from '@pages/inbox/report/ReportActionCompose/getCursorPosition'; -import getScrollPosition from '@pages/inbox/report/ReportActionCompose/getScrollPosition'; -import type {SuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; -import SilentCommentUpdater from '@pages/inbox/report/ReportActionCompose/SilentCommentUpdater'; -import Suggestions from '@pages/inbox/report/ReportActionCompose/Suggestions'; -import useLastEditableAction from '@pages/inbox/report/ReportActionCompose/useLastEditableAction'; +import {useReportActionActiveEditActions} from '@pages/inbox/report/ReportActionEditMessageContext'; +import useDebouncedSaveDraft from '@pages/inbox/report/useDebouncedSaveDraft'; +import useDraftMessageVideoAttributeCache from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import type {OnEmojiSelected} from '@userActions/EmojiPickerAction'; import {inputFocusChange} from '@userActions/InputFocus'; @@ -64,10 +58,21 @@ import {broadcastUserIsTyping, saveReportActionDraft, saveReportDraftComment} fr import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; // eslint-disable-next-line no-restricted-imports import findNodeHandle from '@src/utils/findNodeHandle'; +import {useComposerActions, useComposerEditState, useComposerText} from './ComposerContext'; +import getCursorPosition from './getCursorPosition'; +import getScrollPosition from './getScrollPosition'; +import getUpdatedSyncSelection from './getUpdatedSyncSelection'; +import type {SuggestionsRef} from './ReportActionCompose'; +import ReportActionComposeUtils from './ReportActionComposeUtils'; +import SilentCommentUpdater from './SilentCommentUpdater'; +import Suggestions from './Suggestions'; +import useEditComposerToggle from './useEditComposerToggle'; +import useLastEditableAction from './useLastEditableAction'; type SyncSelection = { position: number; @@ -76,6 +81,26 @@ type SyncSelection = { type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; +type ComposerWithSuggestionsRef = ComposerRef & { + /** Focus the composer */ + focus: (shouldDelay?: boolean, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused?: boolean) => void; + + /** Replace the selection with text */ + replaceSelectionWithText: OnEmojiSelected; + + /** Get the current text of the composer */ + getCurrentText: () => string; + + /** + * Calling clear will immediately clear the input on the UI thread (its a worklet). + * Once the composer ahs cleared onCleared will be called with the value that was cleared. + */ + clearWorklet: () => void; + + /** Reset the height of the composer */ + resetHeight: () => void; +}; + type ComposerWithSuggestionsProps = Partial & ForwardedFSClassProps & { /** Report ID */ @@ -117,9 +142,6 @@ type ComposerWithSuggestionsProps = Partial & /** Function to handle sending a message */ onEnterKeyPress: () => void; - /** Whether the compose input should show */ - shouldShowComposeInput: OnyxEntry; - /** Function to measure the parent container */ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; @@ -145,7 +167,7 @@ type ComposerWithSuggestionsProps = Partial & policyID?: string; /** Reference to the outer element */ - ref?: Ref; + ref?: Ref; }; type SwitchToCurrentReportProps = { @@ -153,30 +175,8 @@ type SwitchToCurrentReportProps = { reportToCopyDraftTo: string; callback: () => void; }; - -type ComposerRef = { - blur: () => void; - focus: (shouldDelay?: boolean) => void; - replaceSelectionWithText: OnEmojiSelected; - getCurrentText: () => string; - isFocused: () => boolean; - - /** - * Calling clear will immediately clear the input on the UI thread (its a worklet). - * Once the composer ahs cleared onCleared will be called with the value that was cleared. - */ - clearWorklet: () => void; - - /** - * Reset the height of the composer. - */ - resetHeight: () => void; -}; - const {RNTextInputReset} = NativeModules; -const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; - /** * Broadcast that the user is typing. Debounced to limit how often we publish client events. */ @@ -223,7 +223,6 @@ function ComposerWithSuggestions({ onPasteFile, disabled, onEnterKeyPress, - shouldShowComposeInput, measureParentContainer = () => {}, isScrollLikelyLayoutTriggered, raiseIsScrollLikelyLayoutTriggered, @@ -256,16 +255,57 @@ function ComposerWithSuggestions({ const cursorPositionValue = useSharedValue({x: 0, y: 0}); const tag = useSharedValue(-1); const isInSidePanel = useIsInSidePanel(); - const value = useComposerText(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const composerRef = useRef(null); + + const {editingState, editingReportActionID, editingReportAction, effectiveDraft, currentEditMessageSelection} = useComposerEditState(); + const {setEditingMessage, setCurrentEditMessageSelection} = useReportActionActiveEditActions(); + + const isEditing = editingState !== CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF; + const text = useComposerText(); const {setText} = useComposerActions(); // Snapshot of provider text at mount — used for one-time selection cursor + emoji baseline + autofocus decision. // Reading the live `value` for these would cause re-renders and effect re-fires on every keystroke. const [initialText] = useState(() => { - if (value) { - emojisPresentBefore.current = extractEmojis(value); + const initialValue = effectiveDraft ?? text; + if (initialValue) { + emojisPresentBefore.current = extractEmojis(initialValue); } - return value; + return initialValue; + }); + + // Save the draft of the report action. This debounced so that we're not ceaselessly saving your edit. + const {saveDraft: debouncedSaveReportActionDraft, isSavePending: isDraftSavePending} = useDebouncedSaveDraft( + useCallback( + (comment: string) => { + saveReportActionDraft(reportID, editingReportAction, comment); + }, + [reportID, editingReportAction], + ), + ); + + // Save the draft of the report comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft + // allows one to navigate somewhere else and come back to the comment and still have it in edit mode. + const {saveDraft: debouncedSaveComment, isSavePending: isCommentSavePending} = useDebouncedSaveDraft( + useCallback( + (comment: string) => { + saveReportDraftComment(reportID, comment); + }, + [reportID], + ), + ); + + useDraftMessageVideoAttributeCache({ + draftMessage: text, + isEditing, + editingReportAction, + updateDraftMessage: setText, + isEditInProgressRef: isDraftSavePending, }); + + const [selection, setSelection] = useState(() => currentEditMessageSelection ?? {start: initialText.length, end: initialText.length}); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const commentRef = useRef(initialText); @@ -276,35 +316,60 @@ function ComposerWithSuggestions({ const shouldDelayAutoFocusRef = useRef(shouldDelayAutoFocus); shouldDelayAutoFocusRef.current = shouldDelayAutoFocus; + /** + * Focus the composer text input + * @param [shouldDelay=false] Impose delay before focusing the composer + * @param [forcedSelectionRange] Optional selection to apply after focus + * @param [forceKeyboardIfAlreadyFocused] When already focused, use KeyboardController so the keyboard can show (e.g. edit-in-composer) + */ + const focus = useCallback((shouldDelay = false, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused = false) => { + // If we're stacked above another RHP, wait for the transition to complete before focusing. + const delay = shouldDelayAutoFocusRef.current ? CONST.ANIMATED_TRANSITION : CONST.COMPOSER_FOCUS_DELAY; + focusComposerWithDelay(composerRef.current, delay)(shouldDelay, forcedSelectionRange, forceKeyboardIfAlreadyFocused).catch(() => {}); + }, []); + + const handleEditFocus = useCallback(() => focus(true, undefined, true), [focus]); + + const handleEditValueChange = useCallback( + (nextValue: string) => { + onValueChange(nextValue); + commentRef.current = nextValue; + emojisPresentBefore.current = extractEmojis(nextValue); + + setText(nextValue); + }, + [onValueChange, setText], + ); + + useEditComposerToggle({ + selection, + composerRef, + onFocus: handleEditFocus, + onValueChange: handleEditValueChange, + onSelectionChange: setSelection, + }); + const [modal] = useOnyx(ONYXKEYS.MODAL); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); const [editFocused] = useOnyx(ONYXKEYS.INPUT_FOCUSED); const lastTextRef = useRef(initialText); useEffect(() => { - lastTextRef.current = value; - }, [value]); + lastTextRef.current = text; + }, [text]); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const shouldAutoFocus = (shouldFocusInputOnScreenFocus || !!value) && shouldShowComposeInput && areAllModalsHidden() && isFocused; + const shouldAutoFocus = (shouldFocusInputOnScreenFocus || !!text) && areAllModalsHidden() && isFocused; const delayedAutoFocusRouteKeyRef = useRef(null); const valueRef = useRef(initialText); - valueRef.current = value; - - const [selection, setSelection] = useState(() => ({start: initialText.length, end: initialText.length, positionX: 0, positionY: 0})); + valueRef.current = text; - const [composerHeightAfterClear, setDefaultComposerHeight] = useState(null); + const [composerHeightAfterClear, setComposerHeightAfterClear] = useState(null); const emptyComposerHeightRef = useRef(null); - const textInputRef = useRef(null); - const syncSelectionWithOnChangeTextRef = useRef(null); - // The ref to check whether the comment saving is in progress - const isCommentPendingSaved = useRef(false); - // Tracks transition state to prevent SilentCommentUpdater from overwriting the just-saved draft during report ID changes const isTransitioningToPreExistingReport = useRef(false); @@ -317,12 +382,12 @@ function ComposerWithSuggestions({ /** * Set the TextInput Ref */ - const setTextInputRef = useCallback( - (el: TextInput) => { + const setComposerRef = useCallback( + (el: ComposerRef) => { if (isFocused) { ReportActionComposeFocusManager.composerRef.current = el; } - textInputRef.current = el; + composerRef.current = el; if (typeof animatedRef === 'function') { animatedRef(el); } @@ -337,15 +402,6 @@ function ComposerWithSuggestions({ RNTextInputReset.resetKeyboardInput(CONST.COMPOSER.NATIVE_ID); }, []); - const debouncedSaveReportComment = useMemo( - () => - lodashDebounce((selectedReportID: string, newComment: string | null) => { - saveReportDraftComment(selectedReportID, newComment); - isCommentPendingSaved.current = false; - }, 1000), - [], - ); - useEffect(() => { const switchToCurrentReport = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, ({reportToCopyDraftTo, callback}: SwitchToCurrentReportProps) => { if (!commentRef.current) { @@ -414,7 +470,7 @@ function ComposerWithSuggestions({ // previous text before change const prevText = lastTextRef.current; // snapshot selection (should be the selection that was active just before the paste/change) - const prevSelectionStart = selection?.start ?? 0; + const prevSelectionStart = selection.start ?? 0; const prevSelectionEnd = selection?.end ?? 0; // detect newly added text (existing helper) @@ -436,9 +492,6 @@ function ComposerWithSuggestions({ const commentWithSpaceInserted = isEmojiInserted ? insertWhiteSpaceAtIndex(effectiveCommentValue, endIndex) : effectiveCommentValue; const {text: emojiConvertedText, emojis, cursorPosition} = replaceAndExtractEmojis(commentWithSpaceInserted, preferredSkinTone, preferredLocale); - const newComment = insertTextVSBetweenDigitAndEmoji(emojiConvertedText); - const textVSOffset = getTextVSCursorOffset(emojiConvertedText, cursorPosition); - if (emojis.length) { const newEmojis = getAddedEmojis(emojis, emojisPresentBefore.current); if (newEmojis.length) { @@ -448,49 +501,74 @@ function ComposerWithSuggestions({ } } } + + const newComment = insertTextVSBetweenDigitAndEmoji(emojiConvertedText); const newCommentConverted = convertToLTRForComposer(newComment); emojisPresentBefore.current = emojis; + const textVSOffset = getTextVSCursorOffset(emojiConvertedText, cursorPosition); + setText(newCommentConverted); + onValueChange(newCommentConverted); if (commentValue !== newComment) { const adjustedCursorPosition = cursorPosition !== undefined && cursorPosition !== null ? cursorPosition + textVSOffset : undefined; const position = Math.max((selection.end ?? 0) + (newComment.length - commentRef.current.length), adjustedCursorPosition ?? 0); - if (commentWithSpaceInserted !== newComment && isIOSNative) { - syncSelectionWithOnChangeTextRef.current = {position, value: newComment}; + const updatedSyncSelection = getUpdatedSyncSelection({commentWithSpaceInserted, newComment, position}); + if (updatedSyncSelection) { + syncSelectionWithOnChangeTextRef.current = updatedSyncSelection; } + // Keep selection in sync after emoji conversion / insertion while editing (e.g. emoji picker on web); setSelection((prevSelection) => ({ + ...prevSelection, start: position, end: position, - positionX: prevSelection.positionX, - positionY: prevSelection.positionY, })); + setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: position, end: position})); } commentRef.current = newCommentConverted; + if (editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING && shouldUseNarrowLayout) { + setEditingMessage(newCommentConverted); + if (shouldDebounceSaveComment) { + debouncedSaveReportActionDraft(newCommentConverted); + return; + } + + saveReportActionDraft(reportID, {reportActionID: editingReportActionID} as OnyxTypes.ReportAction, newCommentConverted); + return; + } + if (shouldDebounceSaveComment) { - isCommentPendingSaved.current = true; - debouncedSaveReportComment(reportID, newCommentConverted); + debouncedSaveComment(newCommentConverted); } else { saveReportDraftComment(reportID, newCommentConverted); } + if (newCommentConverted) { debouncedBroadcastUserIsTyping(reportID, currentUserAccountID); } }, [ + raiseIsScrollLikelyLayoutTriggered, + selection.start, + selection.end, findNewlyAddedChars, - preferredLocale, preferredSkinTone, - reportID, + preferredLocale, + setText, + onValueChange, + editingState, + shouldUseNarrowLayout, suggestionsRef, - raiseIsScrollLikelyLayoutTriggered, - debouncedSaveReportComment, - selection?.end, - selection?.start, + setCurrentEditMessageSelection, + setEditingMessage, + reportID, + editingReportActionID, + debouncedSaveReportActionDraft, + debouncedSaveComment, currentUserAccountID, - setText, ], ); @@ -498,10 +576,10 @@ function ComposerWithSuggestions({ * Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker) */ const replaceSelectionWithText = useCallback( - (text: string) => { + (newText: string) => { // selection replacement should be debounced to avoid conflicts with text typing // (f.e. when emoji is being picked and 1 second still did not pass after user finished typing) - updateComment(insertText(commentRef.current, selection, text), true); + updateComment(insertText(commentRef.current, selection, newText), true); }, [selection, updateComment], ); @@ -553,17 +631,34 @@ function ComposerWithSuggestions({ if (lastGraphemeLength > 1) { event.preventDefault(); const newText = lastTextRef.current.slice(0, selection.start - lastGraphemeLength) + lastTextRef.current.slice(selection.start); + const newStart = selection.start - lastGraphemeLength; + const newEnd = selection.start - lastGraphemeLength; + setSelection((prevSelection) => ({ - start: selection.start - lastGraphemeLength, - end: selection.start - lastGraphemeLength, - positionX: prevSelection.positionX, - positionY: prevSelection.positionY, + ...prevSelection, + start: newStart, + end: newEnd, })); + + setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, start: newStart, end: newEnd})); + updateComment(newText, true); } } }, - [shouldUseNarrowLayout, isKeyboardShown, suggestionsRef, selection.start, includeChronos, onEnterKeyPress, lastReportAction, reportID, updateComment, selection.end], + [ + shouldUseNarrowLayout, + isKeyboardShown, + suggestionsRef, + selection.start, + selection.end, + includeChronos, + onEnterKeyPress, + lastReportAction, + reportID, + updateComment, + setCurrentEditMessageSelection, + ], ); /** @@ -574,7 +669,7 @@ function ComposerWithSuggestions({ if (composerHeightAfterClear == null) { return; } - setDefaultComposerHeight(null); + setComposerHeightAfterClear(null); }, [composerHeightAfterClear]); const onChangeText = useCallback( @@ -585,31 +680,32 @@ function ComposerWithSuggestions({ updateComment(commentValue, true); - if (isIOSNative && syncSelectionWithOnChangeTextRef.current) { - const positionSnapshot = syncSelectionWithOnChangeTextRef.current.position; - syncSelectionWithOnChangeTextRef.current = null; - - // ensure that selection is set imperatively after all state changes are effective - InteractionManager.runAfterInteractions(() => { - // note: this implementation is only available on non-web RN, thus the wrapping - // 'if' block contains a redundant (since the ref is only used on iOS) platform check - textInputRef.current?.setSelection(positionSnapshot, positionSnapshot); - }); + if (!syncSelectionWithOnChangeTextRef.current) { + return; } + + const positionSnapshot = syncSelectionWithOnChangeTextRef.current.position; + syncSelectionWithOnChangeTextRef.current = null; + ReportActionComposeUtils.updateNativeSelectionValue(composerRef, positionSnapshot, positionSnapshot); }, [clearComposerHeight, updateComment], ); const onSelectionChange = useCallback( (e: CustomSelectionChangeEvent) => { - setSelection(e.nativeEvent.selection); - - if (!textInputRef.current?.isFocused()) { + const newSelection = {...e.nativeEvent.selection}; + setSelection(newSelection); + setCurrentEditMessageSelection((prevSelection) => ({ + ...prevSelection, + ...newSelection, + })); + + if (!composerRef.current?.isFocused()) { return; } suggestionsRef.current?.onSelectionChange?.(e); }, - [suggestionsRef], + [setCurrentEditMessageSelection, suggestionsRef], ); const hideSuggestionMenu = useCallback( @@ -631,16 +727,6 @@ function ComposerWithSuggestions({ return suggestionsRef.current.setShouldBlockSuggestionCalc(false); }, [suggestionsRef]); - /** - * Focus the composer text input - * @param [shouldDelay=false] Impose delay before focusing the composer - */ - const focus = useCallback((shouldDelay = false) => { - // If we're stacked above another RHP, wait for the transition to complete before focusing. - const delay = shouldDelayAutoFocusRef.current ? CONST.ANIMATED_TRANSITION : CONST.COMPOSER_FOCUS_DELAY; - focusComposerWithDelay(textInputRef.current, delay)(shouldDelay); - }, []); - /** * In the stacked-RHP SearchReport case we disable the TextInput's immediate `autoFocus` to avoid jank. * Make sure we still trigger a (delayed) manual focus on first render for that route. @@ -677,7 +763,7 @@ function ComposerWithSuggestions({ if (!isInSidePanel) { ReportActionComposeFocusManager.sidePanelComposerRef.current = null; } else { - ReportActionComposeFocusManager.sidePanelComposerRef.current = textInputRef.current; + ReportActionComposeFocusManager.sidePanelComposerRef.current = composerRef.current; } }, [isInSidePanel]); @@ -735,13 +821,6 @@ function ComposerWithSuggestions({ [checkComposerVisibility, focus, isSidePanelHiddenOrLargeScreen], ); - const blur = useCallback(() => { - if (!textInputRef.current) { - return; - } - textInputRef.current.blur(); - }, []); - const clearWorklet = useCallback(() => { 'worklet'; @@ -752,7 +831,7 @@ function ComposerWithSuggestions({ if (!emptyComposerHeightRef.current) { return; } - setDefaultComposerHeight(emptyComposerHeightRef.current); + setComposerHeightAfterClear(emptyComposerHeightRef.current); }, []); const getCurrentText = useCallback(() => { @@ -764,7 +843,7 @@ function ComposerWithSuggestions({ const unsubscribeNavigationFocus = navigation.addListener('focus', () => { addKeyDownPressListener(focusComposerOnKeyPress); // The report isn't unmounted and can be focused again after going back from another report so we should update the composerRef again - ReportActionComposeFocusManager.composerRef.current = textInputRef.current; + ReportActionComposeFocusManager.composerRef.current = composerRef.current; setUpComposeFocusManager(); }); addKeyDownPressListener(focusComposerOnKeyPress); @@ -792,7 +871,7 @@ function ComposerWithSuggestions({ // We want to blur the input immediately when a screen is out of focus. if (!isFocused) { - textInputRef.current?.blur(); + composerRef.current?.blur(); return; } @@ -822,33 +901,44 @@ function ComposerWithSuggestions({ useEffect(() => { // Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit - updateMultilineInputRange(textInputRef.current, !!shouldAutoFocus); + updateMultilineInputRange(composerRef.current, !!shouldAutoFocus); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useImperativeHandle( ref, - () => ({ - blur, - focus, - replaceSelectionWithText, - isFocused: () => !!textInputRef.current?.isFocused(), - getCurrentText, - clearWorklet, - resetHeight, - }), - [blur, focus, replaceSelectionWithText, clearWorklet, resetHeight, getCurrentText], + () => + new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'focus') { + return focus; + } + if (prop === 'replaceSelectionWithText') { + return replaceSelectionWithText; + } + if (prop === 'getCurrentText') { + return getCurrentText; + } + if (prop === 'clearWorklet') { + return clearWorklet; + } + if (prop === 'resetHeight') { + return resetHeight; + } + + return composerRef.current?.[prop as keyof ComposerRef]; + }, + }, + ) as ComposerWithSuggestionsRef, ); - useEffect(() => { - onValueChange(value); - }, [onValueChange, value]); - const onClear = useCallback( - (text: string) => { + (textOnClear: string) => { mobileInputScrollPosition.current = 0; // Note: use the value when the clear happened, not the current value which might have changed already - onClearProp(text); + onClearProp(textOnClear); updateComment('', true); }, [onClearProp, updateComment], @@ -856,7 +946,7 @@ function ComposerWithSuggestions({ useEffect(() => { // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. - tag.set(findNodeHandle(textInputRef.current) ?? -1); + tag.set(findNodeHandle(composerRef.current) ?? -1); }, [tag]); useFocusedInputHandler( @@ -876,7 +966,7 @@ function ComposerWithSuggestions({ ); const measureParentContainerAndReportCursor = useCallback( (callback: MeasureParentContainerAndCursorCallback) => { - const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef}); + const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); measureParentContainer((x, y, width, height) => { callback({ @@ -924,18 +1014,26 @@ function ComposerWithSuggestions({ // When using the suggestions box (Suggestions) we need to imperatively // set the cursor to the end of the suggestion/mention after it's selected. - const onSuggestionSelected = useCallback((suggestionSelection: TextSelection) => { - const endOfSuggestionSelection = suggestionSelection.end; - setSelection(suggestionSelection); - - if (endOfSuggestionSelection === undefined) { - return; - } + const onSuggestionSelected = useCallback( + (suggestionSelection: TextSelection) => { + const endOfSuggestionSelection = suggestionSelection.end; + setSelection(suggestionSelection); + setCurrentEditMessageSelection((prevSelection) => ({ + ...prevSelection, + start: suggestionSelection.start, + end: suggestionSelection.end, + })); + + if (endOfSuggestionSelection === undefined) { + return; + } - queueMicrotask(() => { - textInputRef.current?.setSelection?.(endOfSuggestionSelection, endOfSuggestionSelection); - }); - }, []); + queueMicrotask(() => { + composerRef.current?.setSelection?.(endOfSuggestionSelection, endOfSuggestionSelection); + }); + }, + [setCurrentEditMessageSelection], + ); return ( <> @@ -951,7 +1049,7 @@ function ComposerWithSuggestions({ // So we must also prevent the TextInput's immediate `autoFocus` and rely on our delayed manual focus instead. autoFocus={!!shouldAutoFocus && !shouldDelayAutoFocus} multiline - ref={setTextInputRef} + ref={setComposerRef} placeholder={inputPlaceholder} placeholderTextColor={theme.placeholderText} onChangeText={onChangeText} @@ -967,7 +1065,7 @@ function ComposerWithSuggestions({ onBlur={onBlur} onClick={setShouldBlockSuggestionCalcToFalse} onPasteFile={(files) => { - textInputRef.current?.blur(); + composerRef.current?.blur(); onPasteFile(files); }} onClear={onClear} @@ -976,8 +1074,8 @@ function ComposerWithSuggestions({ onSelectionChange={onSelectionChange} isComposerFullSize={isComposerFullSize} onContentSizeChange={handleContentSizeChange} - value={value} - testID="composer" + value={text} + testID={CONST.COMPOSER.NATIVE_ID} shouldCalculateCaretPosition onLayout={onLayout} onScroll={hideSuggestionMenu} @@ -989,13 +1087,13 @@ function ComposerWithSuggestions({ @@ -1021,4 +1119,4 @@ function ComposerWithSuggestions({ export default memo(ComposerWithSuggestions); -export type {ComposerRef}; +export type {ComposerWithSuggestionsRef}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.tsx deleted file mode 100644 index f2aebd390ba6..000000000000 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import ComposerWithSuggestions from './ComposerWithSuggestions'; - -export default ComposerWithSuggestions; diff --git a/src/pages/inbox/report/ReportActionCompose/ExpandCollapseButton.tsx b/src/pages/inbox/report/ReportActionCompose/ExpandCollapseButton.tsx new file mode 100644 index 000000000000..8b92367828c5 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ExpandCollapseButton.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {ViewProps} from 'react-native'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import Tooltip from '@components/Tooltip'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type ExpandCollapseButtonProps = ViewProps & { + isFullComposerAvailable: boolean; + isComposerFullSize: boolean; + reportID: string; + raiseIsScrollLikelyLayoutTriggered: () => void; + setIsComposerFullSize: (reportID: string, isFullSize: boolean) => void; + disabled?: boolean; +}; + +function ExpandCollapseButton({ + isFullComposerAvailable, + isComposerFullSize, + reportID, + disabled = false, + raiseIsScrollLikelyLayoutTriggered, + setIsComposerFullSize, + ...restProps +}: ExpandCollapseButtonProps) { + const {translate} = useLocalize(); + const theme = useTheme(); + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['Collapse', 'Expand'] as const); + + if (!isFullComposerAvailable && !isComposerFullSize) { + return null; + } + + const shouldCollapse = isComposerFullSize; + const tooltipText = shouldCollapse ? translate('reportActionCompose.collapse') : translate('reportActionCompose.expand'); + const nextComposerFullSizeValue = !shouldCollapse; + const iconSrc = shouldCollapse ? icons.Collapse : icons.Expand; + const sentryLabel = shouldCollapse ? CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_COLLAPSE_BUTTON : CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_EXPAND_BUTTON; + + return ( + + + { + e?.preventDefault(); + raiseIsScrollLikelyLayoutTriggered(); + setIsComposerFullSize(reportID, nextComposerFullSizeValue); + }} + // Keep focus on the composer when Collapse/Expand button is clicked. + onMouseDown={(e) => e.preventDefault()} + style={styles.composerSizeButton} + disabled={disabled} + role={CONST.ROLE.BUTTON} + accessibilityLabel={tooltipText} + sentryLabel={sentryLabel} + > + + + + + ); +} + +export default ExpandCollapseButton; diff --git a/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx new file mode 100644 index 000000000000..1fb283111856 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/MessageEditCancelButton.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type {ViewProps} from 'react-native'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import Tooltip from '@components/Tooltip'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type MessageEditCancelButtonProps = ViewProps & { + /** Handle clicking on cancel button */ + onCancel: () => void; + + /** The test ID to use for the button */ + testID?: string; +}; + +function MessageEditCancelButton({onCancel, testID, ...restProps}: MessageEditCancelButtonProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Close']); + + const closeButtonStyles = [styles.composerSizeButton, {marginVertical: styles.composerSizeButton.marginHorizontal}]; + + return ( + + + e.preventDefault()} + sentryLabel={CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_CANCEL_BUTTON} + > + + + + + ); +} + +export default MessageEditCancelButton; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 0850666b6a7c..468fedc534fe 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -6,12 +6,15 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import AgentZeroAwareTypingIndicator from './AgentZeroAwareTypingIndicator'; import ComposerActionMenu from './ComposerActionMenu'; import ComposerBox from './ComposerBox'; +import {useComposerEditState} from './ComposerContext'; import type {SuggestionsRef} from './ComposerContext'; import ComposerDropZone from './ComposerDropZone'; +import ComposerEditingButtons from './ComposerEditingButtons'; import ComposerEmojiPicker from './ComposerEmojiPicker'; import ComposerExceededLength from './ComposerExceededLength'; import ComposerFooter from './ComposerFooter'; @@ -28,12 +31,16 @@ type ReportActionComposeProps = { function ComposerInner({reportID}: ReportActionComposeProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isEditingInComposer} = useComposerEditState(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); return ( - + - + {isEditingInComposer ? : } - + @@ -77,6 +84,7 @@ Composer.ActionMenu = ComposerActionMenu; Composer.Input = ComposerInput; Composer.EmojiPicker = ComposerEmojiPicker; Composer.SendButton = ComposerSendButton; +Composer.EditingButtons = ComposerEditingButtons; Composer.Footer = ComposerFooter; Composer.TypingIndicator = AgentZeroAwareTypingIndicator; Composer.ExceededLength = ComposerExceededLength; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts new file mode 100644 index 000000000000..10faf40d535f --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts @@ -0,0 +1,25 @@ +import type {RefObject} from 'react'; +import type {ComposerRef} from '@components/Composer/types'; +import getPlatform from '@libs/getPlatform'; +import CONST from '@src/CONST'; + +const isIOSNative = getPlatform() === CONST.PLATFORM.IOS; + +const updateNativeSelectionValue = (composerRef: RefObject, start: number, end: number) => { + if (!isIOSNative) { + return; + } + + // Ensure that native selection value is set imperatively after all state changes are effective + requestIdleCallback(() => { + // note: this implementation is only available on non-web RN, thus the wrapping + // 'if' block contains a redundant (since the ref is only used on iOS) platform check + composerRef.current?.setSelection(start, end); + }); +}; + +const ReportActionComposeUtils = { + updateNativeSelectionValue, +}; + +export default ReportActionComposeUtils; diff --git a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx deleted file mode 100644 index 5b0e5a8e41a0..000000000000 --- a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import Icon from '@components/Icon'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import Tooltip from '@components/Tooltip'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; - -type SendButtonProps = { - /** Whether the button is disabled */ - isDisabled: boolean; - - /** Handle clicking on send button */ - handleSendMessage: () => void; -}; - -function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const {translate} = useLocalize(); - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to manage GestureDetector correctly - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); - const icons = useMemoizedLazyExpensifyIcons(['Send']); - const Tap = Gesture.Tap() - .onEnd(() => { - handleSendMessage(); - }) - .runOnJS(true); - - return ( - e.preventDefault()} - > - - - - [ - styles.chatItemSubmitButton, - isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, - isDisabledProp ? styles.cursorDisabled : undefined, - ]} - // Since the parent View has accessible, we need to set accessible to false here to avoid duplicate accessibility elements. - // On Android when TalkBack is enabled, only the parent element should be accessible, otherwise the button will not work. - accessible={false} - focusable={false} - sentryLabel={CONST.SENTRY_LABEL.REPORT.SEND_BUTTON} - > - {({pressed}) => ( - - )} - - - - - - ); -} - -export default SendButton; diff --git a/src/pages/inbox/report/ReportActionCompose/SubmitDraftButton.tsx b/src/pages/inbox/report/ReportActionCompose/SubmitDraftButton.tsx new file mode 100644 index 000000000000..466f4af746ed --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/SubmitDraftButton.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import Icon from '@components/Icon'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import type {PressableWithFeedbackProps} from '@components/Pressable/PressableWithFeedback'; +import Tooltip from '@components/Tooltip'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type SubmitDraftButtonProps = PressableWithFeedbackProps & { + /** The label to display on the button */ + label: string; + + /** The icon to display on the button */ + icon: IconAsset; + + /** Whether the button is disabled */ + isDisabled: boolean; + + /** Handle clicking on send button */ + onPress?: () => void; +}; + +function SubmitDraftButton({isDisabled: isDisabledProp = false, icon, label, sentryLabel, onPress, ...restProps}: SubmitDraftButtonProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + + return ( + + [ + styles.chatItemSubmitButton, + isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, + isDisabledProp ? styles.cursorDisabled : undefined, + ]} + onPress={onPress} + sentryLabel={sentryLabel} + disabled={isDisabledProp} + {...restProps} + > + {({pressed}) => ( + + )} + + + ); +} + +export default SubmitDraftButton; diff --git a/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ios.ts b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ios.ts new file mode 100644 index 000000000000..40c9e12db43e --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ios.ts @@ -0,0 +1,12 @@ +import type GetUpdatedSyncSelection from './types'; + +// We only need to update the sync selection on iOS platforms +const getUpdatedSyncSelection: GetUpdatedSyncSelection = ({commentWithSpaceInserted, newComment, position}) => { + if (commentWithSpaceInserted === newComment) { + return; + } + + return {position, value: newComment}; +}; + +export default getUpdatedSyncSelection; diff --git a/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ts b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ts new file mode 100644 index 000000000000..40bb0fb3f939 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/index.ts @@ -0,0 +1,8 @@ +import type GetUpdatedSyncSelection from './types'; + +const noop = () => undefined; + +// This is a no-op function for non-iOS platforms +const getUpdatedSyncSelection: GetUpdatedSyncSelection = noop; + +export default getUpdatedSyncSelection; diff --git a/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/types.ts b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/types.ts new file mode 100644 index 000000000000..e2f750764272 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/getUpdatedSyncSelection/types.ts @@ -0,0 +1,12 @@ +type GetUpdatedSyncSelectionProps = { + /** The comment with space inserted */ + commentWithSpaceInserted: string; + /** The new comment */ + newComment: string; + /** The position of the comment */ + position: number; +}; + +type GetUpdatedSyncSelection = (props: GetUpdatedSyncSelectionProps) => {position: number; value: string} | undefined; + +export default GetUpdatedSyncSelection; diff --git a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts new file mode 100644 index 000000000000..99a8f2f23e44 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts @@ -0,0 +1,14 @@ +import type UpdateNativeTextInputValue from './types'; + +// We need to manually update the native text prop on iOS platforms, +// in order to force a re-calculation of the composer height and layout, +// when the composer changes in or out of edit mode. +const updateNativeTextInputValue: UpdateNativeTextInputValue = ({text, shouldForceNativeValueUpdate, composerRef}) => { + if (!shouldForceNativeValueUpdate) { + return; + } + + composerRef.current?.setNativeProps({text}); +}; + +export default updateNativeTextInputValue; diff --git a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ts b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ts new file mode 100644 index 000000000000..5b97a110d5e5 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ts @@ -0,0 +1,8 @@ +import type UpdateNativeTextInputValue from './types'; + +const noop = () => undefined; + +// We don't need to manually update the native text prop on non-iOS platforms +const updateNativeTextInputValue: UpdateNativeTextInputValue = noop; + +export default updateNativeTextInputValue; diff --git a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/types.ts b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/types.ts new file mode 100644 index 000000000000..234919473076 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/types.ts @@ -0,0 +1,15 @@ +import type {RefObject} from 'react'; +import type {ComposerRef} from '@components/Composer/types'; + +type UpdateNativeTextInputValueProps = { + /** The text to update */ + text: string; + /** Whether to force a native value update */ + shouldForceNativeValueUpdate: boolean; + /** The ref to the composer */ + composerRef: RefObject; +}; + +type UpdateNativeTextInputValue = (props: UpdateNativeTextInputValueProps) => void; + +export default UpdateNativeTextInputValue; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts index 78ca74dba6e3..3175d2bc8dcb 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts @@ -3,12 +3,12 @@ import type {RefObject} from 'react'; import type {BlurEvent, View} from 'react-native'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import type {SuggestionsRef} from './ComposerContext'; -import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {ComposerWithSuggestionsRef} from './ComposerWithSuggestions'; const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); type UseComposerFocusParams = { - composerRef: RefObject; + composerRef: RefObject; suggestionsRef: RefObject; actionButtonRef: RefObject; initialFocused: boolean; @@ -19,13 +19,6 @@ function useComposerFocus({composerRef, suggestionsRef, actionButtonRef, initial const isKeyboardVisibleWhenShowingModalRef = useRef(false); const isNextModalWillOpenRef = useRef(false); - const focus = () => { - if (composerRef.current === null) { - return; - } - composerRef.current?.focus(true); - }; - const onAddActionPressed = () => { if (!willBlurTextInputOnTapOutside) { isKeyboardVisibleWhenShowingModalRef.current = !!composerRef.current?.isFocused(); @@ -57,7 +50,7 @@ function useComposerFocus({composerRef, suggestionsRef, actionButtonRef, initial setIsFocused(true); }; - return {isFocused, onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef}; + return {isFocused, onBlur, onFocus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef}; } export default useComposerFocus; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 8749065188fb..e09c591482e6 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -21,13 +21,14 @@ import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, import {startSpan} from '@libs/telemetry/activeSpans'; import {generateAccountID} from '@libs/UserUtils'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; +import {setIsComposerFullSize} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import {useComposerMeta} from './ComposerContext'; +import {useComposerActions, useComposerEditActions, useComposerEditState, useComposerMeta, useComposerSendState, useComposerText} from './ComposerContext'; import useSidePanelContext from './useSidePanelContext'; -function useComposerSubmit(reportID: string): (comment: string) => void { +function useComposerSubmit(reportID: string) { const {isOffline} = useNetwork(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); @@ -35,9 +36,15 @@ function useComposerSubmit(reportID: string): (comment: string) => void { const isInSidePanel = useIsInSidePanel(); const sidePanelContext = useSidePanelContext(reportID); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const delegateAccountID = useDelegateAccountID(); - const {attachmentFileRef} = useComposerMeta(); + const {composerRef, attachmentFileRef} = useComposerMeta(); + const composerText = useComposerText(); + const {clearComposer} = useComposerActions(); + const {isSendDisabled, debouncedCommentMaxLengthValidation} = useComposerSendState(); + const {isEditingInComposer, editingMessage, effectiveDraft, didResetComposerHeightWhileEditing, editingState} = useComposerEditState(); + const {publishDraft, setDidResetComposerHeightWhileEditing} = useComposerEditActions(); const {scrollOffsetRef} = useContext(ActionListContext); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); @@ -59,8 +66,21 @@ function useComposerSubmit(reportID: string): (comment: string) => void { const currentUserEmail = currentUserPersonalDetails.email ?? ''; - return (newComment: string) => { - const newCommentTrimmed = newComment.trim(); + /** + * Add or edit a comment in the composer + */ + const validateAndSubmitDraft = (draftMessage: string) => { + const draftMessageTrimmed = draftMessage.trim(); + + const isSubmittingEdit = isEditingInComposer || didResetComposerHeightWhileEditing; + if (isSubmittingEdit && !attachmentFileRef.current) { + publishDraft(draftMessageTrimmed); + return; + } + + if (!draftMessageTrimmed && !attachmentFileRef.current) { + return; + } if (attachmentFileRef.current) { addAttachmentWithComment({ @@ -69,7 +89,7 @@ function useComposerSubmit(reportID: string): (comment: string) => void { ancestors: targetReportAncestors, attachments: attachmentFileRef.current, currentUserAccountID: currentUserPersonalDetails.accountID, - text: newCommentTrimmed, + text: draftMessageTrimmed, timezone: currentUserPersonalDetails.timezone, shouldPlaySound: true, isInSidePanel, @@ -80,7 +100,7 @@ function useComposerSubmit(reportID: string): (comment: string) => void { return; } - const taskMatch = newCommentTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + const taskMatch = draftMessageTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); if (taskMatch) { let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; if (taskTitle) { @@ -134,7 +154,7 @@ function useComposerSubmit(reportID: string): (comment: string) => void { op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, attributes: { [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, - [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, + [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: draftMessageTrimmed.length, }, }); } @@ -142,7 +162,7 @@ function useComposerSubmit(reportID: string): (comment: string) => void { report: targetReport, notifyReportID: reportID, ancestors: targetReportAncestors, - text: newCommentTrimmed, + text: draftMessageTrimmed, timezoneParam: currentUserPersonalDetails.timezone ?? CONST.DEFAULT_TIME_ZONE, currentUserAccountID: currentUserPersonalDetails.accountID, shouldPlaySound: true, @@ -152,6 +172,40 @@ function useComposerSubmit(reportID: string): (comment: string) => void { delegateAccountID, }); }; + + const submitDraftAndClearComposer = () => { + if (isSendDisabled || !debouncedCommentMaxLengthValidation?.flush()) { + return; + } + + if (isComposerFullSize) { + setIsComposerFullSize(reportID, false); + } + + const isFinishingComposerEdit = + editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING && (isEditingInComposer || didResetComposerHeightWhileEditing) && !attachmentFileRef.current; + + if (isFinishingComposerEdit) { + const hasNonEmptyEditingMessage = editingMessage !== null && editingMessage !== ''; + const draftMessageForEdit = hasNonEmptyEditingMessage ? editingMessage : composerText; + validateAndSubmitDraft(draftMessageForEdit); + return; + } + + if (effectiveDraft !== null && effectiveDraft !== '') { + composerRef.current?.resetHeight(); + if (isEditingInComposer) { + setDidResetComposerHeightWhileEditing(true); + } + } + + clearComposer(); + }; + + return { + validateAndSubmitDraft, + submitDraftAndClearComposer, + }; } export default useComposerSubmit; diff --git a/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts b/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts new file mode 100644 index 000000000000..ce9495104e78 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation.ts @@ -0,0 +1,50 @@ +import lodashDebounce from 'lodash/debounce'; +import {useState} from 'react'; +import {getCommentLength} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; + +type UseDebouncedCommentValidationProps = { + /** The report ID */ + reportID: string | undefined; + + /** Whether the composer is in edit mode */ + isEditing?: boolean; +}; + +function useDebouncedCommentMaxLengthValidation({reportID, isEditing = false}: UseDebouncedCommentValidationProps) { + const [exceededMaxLength, setExceededMaxLength] = useState(null); + const [isTaskTitle, setIsTaskTitle] = useState(false); + + /** + * Updates the composer when the comment length is exceeded + * Shows red borders and prevents the comment from being sent + * When editing, we only validate comment length; task title rules do not apply. + */ + function validateMaxLength(value: string) { + const taskCommentMatch = value?.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + + // Only apply task-title validation when composing (not when editing an existing message) + if (!isEditing && taskCommentMatch) { + const title = taskCommentMatch?.[3] ? taskCommentMatch[3].trim().replaceAll('\n', ' ') : ''; + const exceeded = title ? title.length > CONST.TITLE_CHARACTER_LIMIT : false; + + setIsTaskTitle(exceeded); + setExceededMaxLength(exceeded ? CONST.TITLE_CHARACTER_LIMIT : null); + + return !exceeded; + } + + const exceeded = getCommentLength(value, {reportID}) > CONST.MAX_COMMENT_LENGTH; + + setIsTaskTitle(false); + setExceededMaxLength(exceeded ? CONST.MAX_COMMENT_LENGTH : null); + + return !exceeded; + } + + const debouncedCommentMaxLengthValidation = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}); + + return {debouncedCommentMaxLengthValidation, exceededMaxLength, isTaskTitle, isExceedingMaxLength: !!exceededMaxLength}; +} + +export default useDebouncedCommentMaxLengthValidation; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts new file mode 100644 index 000000000000..fced496d634a --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -0,0 +1,157 @@ +import {useEffect, useRef} from 'react'; +import type {RefObject} from 'react'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import CONST from '@src/CONST'; +import {useComposerEditActions, useComposerEditState, useComposerText} from './ComposerContext'; +import ReportActionComposeUtils from './ReportActionComposeUtils'; +import updateNativeTextInputValue from './updateNativeTextInputValue'; + +type UseEditComposerToggleProps = { + /** The selection of the composer */ + selection: TextSelection; + + /** The ref to the composer */ + composerRef: RefObject; + + /** Handle changing the selection of the composer */ + onSelectionChange?: (selection: TextSelection) => void; + + /** Handle focusing the composer */ + onFocus?: () => void; + + /** Handle changing the value of the composer */ + onValueChange?: (value: string) => void; +}; + +/** + * useEditComposerToggle is a hook that manages the editing state of the composer. + * It is used to toggle the editing state of the composer and to apply the changes to the composer. + * Additionally, it is used to restore the draft comment and the selection when the editing state is toggled off, + * to focus the composer when the editing state is toggled on, + * to update the value of the composer when the editing state is toggled on, + * and to update the selection of the composer when the editing state is toggled on. + */ +function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, onSelectionChange}: UseEditComposerToggleProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const {isEditingInComposer, editingState, editingReportActionID, editingMessage, currentEditMessageSelection, draftComment} = useComposerEditState(); + const {setDidResetComposerHeightWhileEditing} = useComposerEditActions(); + const text = useComposerText(); + const isEditing = editingState !== CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF; + + const wasEditingRef = useRef(isEditing); + const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); + const previousDraftSelectionRef = useRef(null); + const previousEditingReportActionIDRef = useRef(null); + const previousTextRef = useRef(null); + + type ApplyComposerValueOptions = { + isEditingInComposer?: boolean; + shouldMoveSelectionToEnd?: boolean; + selection?: TextSelection | null; + shouldForceNativeValueUpdate?: boolean; + }; + + const applyComposerValue = (nextValue: string, options?: ApplyComposerValueOptions) => { + const defaultSelection: TextSelection = {start: nextValue.length, end: nextValue.length}; + const shouldUseEditingSelection = options?.isEditingInComposer ?? false; + const shouldForceSelectionToEnd = options?.shouldMoveSelectionToEnd ?? false; + const explicitSelection = options?.selection ?? null; + + const selectionToApply = explicitSelection ?? (shouldUseEditingSelection && !shouldForceSelectionToEnd ? (currentEditMessageSelection ?? defaultSelection) : defaultSelection); + + previousTextRef.current = nextValue; + onValueChange?.(nextValue); + updateNativeTextInputValue({text: nextValue, shouldForceNativeValueUpdate: options?.shouldForceNativeValueUpdate ?? false, composerRef}); + + onSelectionChange?.(selectionToApply); + ReportActionComposeUtils.updateNativeSelectionValue(composerRef, selectionToApply.start, selectionToApply.end ?? selectionToApply.start); + + if (options?.isEditingInComposer) { + onFocus?.(); + } + }; + + useEffect(() => { + // If the draft message is already being submitted, do nothing. + if (editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.SUBMITTED) { + return; + } + + if (editingState !== CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING) { + if (wasEditingRef.current && wasEditingInComposerRef.current) { + // Editing just ended in the composer – restore the draft comment and its previous selection. + applyComposerValue(draftComment ?? '', {selection: previousDraftSelectionRef.current, shouldForceNativeValueUpdate: true}); + + // Once the composer is no longer in edit mode, we can reset the manual composer height. + if (wasEditingInComposerRef.current) { + setDidResetComposerHeightWhileEditing(false); + } + } + + // When editing ends, focus the main composer again. + onFocus?.(); + + wasEditingRef.current = false; + wasEditingInComposerRef.current = shouldUseNarrowLayout; + previousDraftSelectionRef.current = null; + return; + } + + // Editing just started. + if (!wasEditingRef.current) { + // Store the draft selection before switching into edit mode so we can restore it later. + previousDraftSelectionRef.current = selection; + + wasEditingRef.current = true; + wasEditingInComposerRef.current = shouldUseNarrowLayout; + + if (!shouldUseNarrowLayout) { + // Wide layout – another editor handles the edit, keep composer draft as-is. + return; + } + // In narrow layout we always show the message being edited. + // When starting to edit in the composer, always place the cursor at the end of the message. + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldMoveSelectionToEnd: true, shouldForceNativeValueUpdate: true}); + return; + } + + // Editing is ongoing and layout toggled from wide to narrow. + if (shouldUseNarrowLayout && !wasEditingInComposerRef.current) { + wasEditingInComposerRef.current = true; + // We just moved from wide to narrow while editing – start editing in the composer. + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); + return; + } + + // Editing is ongoing and layout toggled from narrow to wide. + if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { + wasEditingInComposerRef.current = false; + applyComposerValue(draftComment ?? ''); + return; + } + + // The editing report action and message changed + if (shouldUseNarrowLayout && editingReportActionID !== previousEditingReportActionIDRef.current) { + applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldForceNativeValueUpdate: true}); + previousEditingReportActionIDRef.current = editingReportActionID; + } + }, [ + applyComposerValue, + composerRef, + text, + editingMessage, + editingReportActionID, + editingState, + isEditingInComposer, + onFocus, + selection, + setDidResetComposerHeightWhileEditing, + shouldUseNarrowLayout, + draftComment, + onValueChange, + ]); +} + +export default useEditComposerToggle; diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts new file mode 100644 index 000000000000..39bed0fd7fb2 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -0,0 +1,109 @@ +// eslint-disable-next-line lodash/import-scope +import type {DebouncedFuncLeading} from 'lodash'; +import type React from 'react'; +import type {ComposerRef} from '@components/Composer/types'; +import useAncestors from '@hooks/useAncestors'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportScrollManager from '@hooks/useReportScrollManager'; +import {clearAllReportActionDrafts, editReportComment} from '@libs/actions/Report'; +import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; +import {getOriginalReportID} from '@libs/ReportUtils'; +import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import {useReportActionActiveEditActions} from '@pages/inbox/report/ReportActionEditMessageContext'; +import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +type UseEditMessageProps = { + /** The report ID */ + reportID: string | undefined; + /** The original report ID */ + originalReportID: string | undefined; + /** The report action */ + reportAction: OnyxTypes.ReportAction | null | undefined; + /** Whether to scroll to the last message */ + shouldScrollToLastMessage?: boolean; + /** The debounced comment max length validation */ + debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>; + /** The ref to the composer */ + composerRef: React.RefObject; +}; + +/** + * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. + */ +function useEditMessage({reportID, originalReportID, reportAction, shouldScrollToLastMessage = false, debouncedCommentMaxLengthValidation, composerRef}: UseEditMessageProps) { + const reportScrollManager = useReportScrollManager(); + + const {email} = useCurrentUserPersonalDetails(); + const actionOwnerReportID = originalReportID ?? reportID; + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${actionOwnerReportID}`); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); + const originalParentReportID = getOriginalReportID(actionOwnerReportID, reportAction, reportActions); + const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${actionOwnerReportID}`); + const isOriginalReportArchived = useReportIsArchived(actionOwnerReportID); + const isOriginalParentReportArchived = useReportIsArchived(originalParentReportID); + const ancestors = useAncestors(originalReport); + + const {stopEditing, submitEdit} = useReportActionActiveEditActions(); + + function deleteDraft(): void { + if (!reportAction) { + return; + } + + stopEditing(); + + clearAllReportActionDrafts(); + + // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. + if (shouldScrollToLastMessage) { + reportScrollManager.scrollToIndex(0, false); + } + } + + /** + * Save the draft of the comment to be the new comment message. This will take the comment out of "edit mode" with + * the new content. + */ + function publishDraft(draftMessage: string) { + if (!reportAction) { + return; + } + + // Do nothing if draft exceed the character limit + if (!debouncedCommentMaxLengthValidation.flush()) { + return; + } + + const trimmedNewDraft = draftMessage.trim(); + + // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. + if (!trimmedNewDraft) { + composerRef.current?.blur(); + ReportActionContextMenu.showDeleteModal(actionOwnerReportID, reportAction, true, deleteDraft, () => focusEditAfterCancelDelete(composerRef.current)); + return; + } + + submitEdit(); + + editReportComment( + originalReport, + reportAction, + ancestors, + trimmedNewDraft, + isOriginalReportArchived, + isOriginalParentReportArchived, + email ?? '', + Object.fromEntries(draftMessageVideoAttributeCache), + visibleReportActionsData, + ); + deleteDraft(); + } + + return {publishDraft, deleteDraft}; +} + +export default useEditMessage; diff --git a/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts b/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts index 4a454111f10a..6b8331066b90 100644 --- a/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts +++ b/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts @@ -9,7 +9,6 @@ import {canEditFieldOfMoneyRequest, canUserPerformWriteAction as canUserPerformW import {getTransactionID} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAction} from '@src/types/onyx'; function useShouldAddOrReplaceReceipt(reportID: string) { const {isOffline} = useNetwork(); @@ -24,7 +23,8 @@ function useShouldAddOrReplaceReceipt(reportID: string) { const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); const isExpensesReport = reportTransactions && reportTransactions.length > 1; - const iouAction = rawReportActions ? (Object.values(rawReportActions).find((action) => isMoneyRequestAction(action)) as ReportAction | undefined) : undefined; + const reportActionValues = rawReportActions ? Object.values(rawReportActions) : []; + const iouAction = reportActionValues.find((action) => isMoneyRequestAction(action)); const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; const transactionID = getTransactionID(report) ?? linkedTransactionID; diff --git a/src/pages/inbox/report/ReportActionEditMessageContext.tsx b/src/pages/inbox/report/ReportActionEditMessageContext.tsx new file mode 100644 index 000000000000..5b6d38fea590 --- /dev/null +++ b/src/pages/inbox/report/ReportActionEditMessageContext.tsx @@ -0,0 +1,199 @@ +import React, {createContext, useContext, useState} from 'react'; +import type {Dispatch, SetStateAction} from 'react'; +import type {ValueOf} from 'type-fest'; +import type {TextSelection} from '@components/Composer/types'; +import useTransactionThreadReportID from '@hooks/useTransactionThreadReportID'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; +import useActiveDraftReportAction from './useActiveDraftReportAction'; + +function noop() { + return null; +} + +/** Whether the report is currently being edited, is already submitted or is not editing any m */ +type ReportActionEditMessageState = ValueOf; + +type ReportActionActiveEdit = { + /** The report ID */ + editingReportID: string | null; + /** The report action ID */ + editingReportActionID: string | null; + /** The report action */ + editingReportAction: OnyxTypes.ReportAction | null; + /** The editing message */ + editingMessage: string | null; +}; + +type ReportActionEditMessageContextValue = ReportActionActiveEdit & { + /** The current edit message selection */ + currentEditMessageSelection: TextSelection | null; + /** The editing state */ + editingState: ReportActionEditMessageState; +}; + +type ReportActionEditMessageContextActions = { + /** Set the editing message */ + setEditingMessage: Dispatch>; + /** Set the current edit message selection */ + setCurrentEditMessageSelection: Dispatch>; + /** Submit the edit */ + submitEdit: () => void; + /** Stop the editing */ + stopEditing: () => void; +}; + +const ReportActionEditMessageContext = createContext({ + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF, + editingReportID: null, + editingReportActionID: null, + editingReportAction: null, + editingMessage: null, + currentEditMessageSelection: null, +}); + +const ReportActionEditMessageActionsContext = createContext({ + setEditingMessage: noop, + setCurrentEditMessageSelection: noop, + submitEdit: noop, + stopEditing: noop, +}); + +type ReportActionEditMessageContextProviderProps = { + /** The report ID */ + reportID: string | undefined; + /** + * When set, drafts for edits that render on money-request views but persist under the + * one-transaction thread report are wired into this provider. Omit on non-money-request + * screens, or supply the effective ID from `useTransactionThreadReportID` / + * `ReportScreenEditMessageProviderWithTransactionThread`. + */ + effectiveTransactionThreadReportID?: string; + /** The children */ + children: React.ReactNode; +}; + +function ReportActionEditMessageContextProvider({reportID, effectiveTransactionThreadReportID, children}: ReportActionEditMessageContextProviderProps) { + const activeDraftEditResolution = useActiveDraftReportAction({effectiveTransactionThreadReportID, reportID}); + + const [editingState, setEditingState] = useState(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF); + const [prevEditingReportActionID, setPrevEditingReportActionID] = useState(null); + const [editingMessage, setEditingMessage] = useState(null); + const [currentEditMessageSelection, setCurrentEditMessageSelectionState] = useState(null); + + let editingReportID: string | null = null; + let editingReportActionID: string | null = null; + let editingReportAction: OnyxTypes.ReportAction | null = null; + + const syncComposerDraftFromPersistedOnyxDraft = (activePersistedDraftReportActionID: string, persistedDraftMessagePreview: string | null) => { + if (persistedDraftMessagePreview == null) { + return; + } + + const didFocusShiftToDistinctReportAction = prevEditingReportActionID !== activePersistedDraftReportActionID; + if (!didFocusShiftToDistinctReportAction) { + return; + } + + setEditingMessage(persistedDraftMessagePreview); + setPrevEditingReportActionID(activePersistedDraftReportActionID); + const defaultSelection: TextSelection = { + start: persistedDraftMessagePreview.length, + end: persistedDraftMessagePreview.length, + }; + setCurrentEditMessageSelectionState(defaultSelection); + }; + + // Bridge resolved Onyx drafts into composing state (`useActiveDraftReportAction` encapsulates ancestry / visible / transaction-thread precedence). + if (activeDraftEditResolution !== null) { + editingReportID = activeDraftEditResolution.editingReportID; + editingReportActionID = activeDraftEditResolution.editingReportActionID; + editingReportAction = activeDraftEditResolution.editingReportAction; + + if (editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF) { + setEditingState(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING); + } + + syncComposerDraftFromPersistedOnyxDraft(activeDraftEditResolution.editingReportActionID, activeDraftEditResolution.draftMessage); + } + + const submitEdit = () => { + setEditingState(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.SUBMITTED); + }; + + const stopEditing = () => { + setEditingState(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF); + setEditingMessage(null); + setPrevEditingReportActionID(null); + setCurrentEditMessageSelectionState(null); + }; + + if (editingReportID == null && editingState !== CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF) { + stopEditing(); + } + + const setCurrentEditMessageSelection = (setSelectionStateAction: SetStateAction) => { + if (editingState !== CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING) { + return; + } + + setCurrentEditMessageSelectionState(setSelectionStateAction); + }; + + const reportActionEditMessageContextValue: ReportActionEditMessageContextValue = { + editingState, + editingReportID, + editingReportActionID, + editingReportAction, + editingMessage, + currentEditMessageSelection, + }; + + const actions: ReportActionEditMessageContextActions = { + setEditingMessage, + setCurrentEditMessageSelection, + submitEdit, + stopEditing, + }; + + return ( + + {children} + + ); +} + +type ReportScreenEditMessageProviderWithTransactionThreadProps = { + reportID: string | undefined; + children: React.ReactNode; +}; + +/** Wires `effectiveTransactionThreadReportID` from `useTransactionThreadReportID` for money-request report views. */ +function ReportScreenEditMessageProviderWithTransactionThread({reportID, children}: ReportScreenEditMessageProviderWithTransactionThreadProps) { + const {effectiveTransactionThreadReportID} = useTransactionThreadReportID(reportID); + return ( + + {children} + + ); +} + +function useReportActionActiveEdit() { + return useContext(ReportActionEditMessageContext); +} + +function useReportActionActiveEditActions() { + return useContext(ReportActionEditMessageActionsContext); +} + +export { + ReportActionEditMessageContextProvider, + ReportScreenEditMessageProviderWithTransactionThread, + useReportActionActiveEdit, + useReportActionActiveEditActions, + ReportActionEditMessageContext, +}; +export type {ReportActionActiveEdit, ReportActionEditMessageContextValue, ReportActionEditMessageState}; diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index fb2d43384fc4..839c78a909bb 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -10,11 +10,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Transaction} from '@src/types/onyx'; import type {PureReportActionItemProps} from './PureReportActionItem'; import PureReportActionItem from './PureReportActionItem'; +import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; type ReportActionItemProps = PureReportActionItemProps & { - /** Whether to show the draft message or not */ - shouldShowDraftMessage?: boolean; - /** Draft message for the report action */ draftMessage?: string; @@ -31,7 +29,7 @@ type ReportActionItemProps = PureReportActionItemProps & { function ReportActionItem({ action, report, - draftMessage, + draftMessage: draftMessageProp, personalDetails, userBillingFundID, linkedTransactionRouteError: linkedTransactionRouteErrorProp, @@ -56,6 +54,10 @@ function ReportActionItem({ const [linkedTransactionRouteError] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {selector: getLinkedTransactionRouteError}); + const {editingMessage, editingReportAction} = useReportActionActiveEdit(); + const draftMessageFromEditingContext = editingReportAction && action && editingReportAction.reportActionID === action.reportActionID ? (editingMessage ?? undefined) : undefined; + const draftMessage = draftMessageProp ?? draftMessageFromEditingContext; + return ( ; + ref?: React.Ref; }; const shouldUseForcedSelectionRange = shouldUseEmojiPickerSelection(); -// video source -> video attributes -const draftMessageVideoAttributeCache = new Map(); - const DEFAULT_MODAL_VALUE = { willAlertModalBecomeVisible: false, isVisible: false, }; -function ReportActionItemMessageEdit({ - action, - draftMessage, - reportID, - originalReportID, - policyID, - index, - isGroupPolicyReport, - shouldDisableEmojiPicker = false, - ref, -}: ReportActionItemMessageEditProps) { +function ReportActionItemMessageEdit({action, reportID, originalReportID, policyID, index, isGroupPolicyReport, shouldDisableEmojiPicker = false, ref}: ReportActionItemMessageEditProps) { const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); - const {email} = useCurrentUserPersonalDetails(); - const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const containerRef = useRef(null); @@ -121,22 +93,44 @@ function ReportActionItemMessageEdit({ const {translate, preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const prevDraftMessage = usePrevious(draftMessage); const suggestionsRef = useRef(null); const mobileInputScrollPosition = useRef(0); const cursorPositionValue = useSharedValue({x: 0, y: 0}); const tag = useSharedValue(-1); const emojisPresentBefore = useRef([]); + const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); + + const {currentEditMessageSelection, editingMessage} = useReportActionActiveEdit(); + const {setEditingMessage, setCurrentEditMessageSelection} = useReportActionActiveEditActions(); const [draft, setDraft] = useState(() => { - if (draftMessage) { - emojisPresentBefore.current = extractEmojis(draftMessage); + if (editingMessage) { + emojisPresentBefore.current = extractEmojis(editingMessage); } - return draftMessage; + return editingMessage ?? ''; }); - const [selection, setSelection] = useState({start: draft.length, end: draft.length, positionX: 0, positionY: 0}); + + const defaultSelection = useMemo(() => ({start: draft.length, end: draft.length, positionX: 0, positionY: 0}), [draft.length]); + const [selection, setSelectionState] = useState(() => currentEditMessageSelection ?? defaultSelection); + + const setSelection = useCallback( + (newSelection: TextSelection) => { + setSelectionState(newSelection); + setCurrentEditMessageSelection((prevSelection) => ({...prevSelection, ...newSelection})); + }, + [setSelectionState, setCurrentEditMessageSelection], + ); + + useEffect(() => { + // When the current edit message selection changes, we need to update the selection state + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelectionState(currentEditMessageSelection ?? defaultSelection); + }, [currentEditMessageSelection, defaultSelection, draft.length, setSelection]); + const [isFocused, setIsFocused] = useState(false); - const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength(); - const debouncedValidateCommentMaxLength = useMemo(() => lodashDebounce(validateCommentMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME), [validateCommentMaxLength]); + const {debouncedCommentMaxLengthValidation, isExceedingMaxLength, exceededMaxLength} = useDebouncedCommentMaxLengthValidation({ + reportID, + isEditing: true, + }); const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); @@ -145,23 +139,24 @@ function ReportActionItemMessageEdit({ const {isScrolling, startScrollBlock, endScrollBlock} = useScrollBlocker(); - const textInputRef = useRef<(HTMLTextAreaElement & TextInput) | null>(null); - const isFocusedRef = useRef(false); + const composerRef = useRef(null); const draftRef = useRef(draft); const emojiPickerSelectionRef = useRef(undefined); - // The ref to check whether the comment saving is in progress - const isCommentPendingSaved = useRef(false); - const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const isOriginalReportArchived = useReportIsArchived(originalReportID); - const originalParentReportID = getOriginalReportID(originalReportID, action, reportActions); - const isOriginalParentReportArchived = useReportIsArchived(originalParentReportID); - const ancestors = useAncestors(originalReport); - const icons = useMemoizedLazyExpensifyIcons(['Checkmark', 'Close']); + + // Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft + // allows one to navigate somewhere else and come back to the comment and still have it in edit mode. + const {saveDraft, isSavePending: isDraftSavePending} = useDebouncedSaveDraft(saveReportActionDraft); + + useDraftMessageVideoAttributeCache({ + draftMessage: editingMessage ?? '', + isEditing: true, + editingReportAction: action, + updateDraftMessage: setDraft, + isEditInProgressRef: isDraftSavePending, + }); useEffect(() => { - focusComposerWithDelay(textInputRef.current)(true); + focusComposerWithDelay(composerRef.current)(true); }, []); // If the underlying action becomes deleted while the user has it open in @@ -170,33 +165,21 @@ function ReportActionItemMessageEdit({ if (!isDeletedAction(action)) { return; } - deleteReportActionDraft(reportID, action); + clearAllReportActionDrafts(); }, [action, reportID]); useEffect(() => { - draftMessageVideoAttributeCache.clear(); - - const originalMessage = Parser.htmlToMarkdown(getReportActionHtml(action), { - cacheVideoAttributes: (videoSource, attrs) => draftMessageVideoAttributeCache.set(videoSource, attrs), - }); - if (isDeletedAction(action) || !!(action.message && draftMessage === originalMessage) || !!(prevDraftMessage === draftMessage || isCommentPendingSaved.current)) { - return; - } - setDraft(draftMessage); - }, [draftMessage, action, prevDraftMessage]); - - useEffect(() => { - composerFocusKeepFocusOn(textInputRef.current as HTMLElement, isFocused, modal, onyxInputFocused); + composerFocusKeepFocusOn(composerRef.current as HTMLElement, isFocused, modal, onyxInputFocused); }, [isFocused, modal, onyxInputFocused]); useEffect( // Remove focus callback on unmount to avoid stale callbacks () => { - if (textInputRef.current) { - ReportActionComposeFocusManager.editComposerRef.current = textInputRef.current; + if (composerRef.current) { + ReportActionComposeFocusManager.editComposerRef.current = composerRef.current; } return () => { - if (ReportActionComposeFocusManager.editComposerRef.current !== textInputRef.current) { + if (ReportActionComposeFocusManager.editComposerRef.current !== composerRef.current) { return; } ReportActionComposeFocusManager.clear(true); @@ -205,18 +188,12 @@ function ReportActionItemMessageEdit({ [], ); - // We consider the report action active if it's focused, its emoji picker is open or its context menu is open - const isActive = useCallback( - () => isFocusedRef.current || isEmojiPickerActive(action.reportActionID) || ReportActionContextMenu.isActiveReportAction(action.reportActionID), - [action.reportActionID], - ); - /** * Focus the composer text input * @param shouldDelay - Impose delay before focusing the composer */ const focus = useCallback((shouldDelay = false, forcedSelectionRange?: Selection) => { - focusComposerWithDelay(textInputRef.current)(shouldDelay, forcedSelectionRange); + focusComposerWithDelay(composerRef.current)(shouldDelay, forcedSelectionRange); }, []); // Take over focus priority @@ -227,31 +204,6 @@ function ReportActionItemMessageEdit({ }, true); }, [focus]); - // show the composer after editing is complete for devices that hide the composer during editing. - useEffect(() => () => setShouldShowComposeInput(true), []); - - /** - * Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft - * allows one to navigate somewhere else and come back to the comment and still have it in edit mode. - * @param {String} newDraft - */ - const debouncedSaveDraft = useMemo( - () => - lodashDebounce((newDraft: string) => { - saveReportActionDraft(reportID, action, newDraft); - isCommentPendingSaved.current = false; - }, 1000), - [reportID, action], - ); - - useEffect( - () => () => { - debouncedSaveDraft.cancel(); - isCommentPendingSaved.current = false; - }, - [debouncedSaveDraft], - ); - /** * Update the value of the draft in Onyx * @@ -281,11 +233,12 @@ function ReportActionItemMessageEdit({ draftRef.current = newDraft; + setEditingMessage(newDraft); + // We want to escape the draft message to differentiate the HTML from the report action and the HTML the user drafted. - debouncedSaveDraft(newDraft); - isCommentPendingSaved.current = true; + saveDraft(reportID, action, newDraft); }, - [raiseIsScrollLayoutTriggered, debouncedSaveDraft, preferredSkinTone, preferredLocale, selection.end], + [action, preferredLocale, preferredSkinTone, raiseIsScrollLayoutTriggered, reportID, selection.end, setEditingMessage, setSelection, saveDraft], ); useEffect(() => { @@ -293,70 +246,14 @@ function ReportActionItemMessageEdit({ // eslint-disable-next-line react-hooks/exhaustive-deps -- run this only when language is changed }, [action.reportActionID, preferredLocale]); - /** - * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. - */ - const deleteDraft = useCallback(() => { - deleteReportActionDraft(reportID, action); - - if (isActive()) { - ReportActionComposeFocusManager.clear(true); - // Wait for report action compose re-mounting on mWeb - InteractionManager.runAfterInteractions(() => ReportActionComposeFocusManager.focus()); - } - - // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. - if (index === 0) { - KeyboardUtils.dismiss().then(() => { - reportScrollManager.scrollToIndex(index, false); - }); - } - }, [action, index, reportID, reportScrollManager, isActive]); - - /** - * Save the draft of the comment to be the new comment message. This will take the comment out of "edit mode" with - * the new content. - */ - const publishDraft = useCallback(() => { - // Do nothing if draft exceed the character limit - if (!debouncedValidateCommentMaxLength.flush()) { - return; - } - - const trimmedNewDraft = draft.trim(); - - // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. - if (!trimmedNewDraft) { - textInputRef.current?.blur(); - ReportActionContextMenu.showDeleteModal(originalReportID ?? reportID, action, true, deleteDraft, () => focusEditAfterCancelDelete(textInputRef.current)); - return; - } - editReportComment( - originalReport, - action, - ancestors, - trimmedNewDraft, - isOriginalReportArchived, - isOriginalParentReportArchived, - email ?? '', - Object.fromEntries(draftMessageVideoAttributeCache), - visibleReportActionsData ?? undefined, - ); - deleteDraft(); - }, [ + const {publishDraft, deleteDraft} = useEditMessage({ reportID, - action, - ancestors, - deleteDraft, - draft, originalReportID, - isOriginalReportArchived, - originalReport, - isOriginalParentReportArchived, - debouncedValidateCommentMaxLength, - email, - visibleReportActionsData, - ]); + reportAction: action, + shouldScrollToLastMessage: index === 0, + debouncedCommentMaxLengthValidation, + composerRef, + }); /** * @param emoji @@ -420,13 +317,13 @@ function ReportActionItemMessageEdit({ } if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !keyEvent.shiftKey) { e.preventDefault(); - publishDraft(); + publishDraft(draft); } else if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { e.preventDefault(); deleteDraft(); } }, - [deleteDraft, hideSuggestionMenu, isKeyboardShown, shouldUseNarrowLayout, publishDraft], + [shouldUseNarrowLayout, isKeyboardShown, hideSuggestionMenu, publishDraft, draft, deleteDraft], ); const measureContainer = useCallback((callback: MeasureInWindowOnSuccessCallback) => { @@ -439,7 +336,7 @@ function ReportActionItemMessageEdit({ const measureParentContainerAndReportCursor = useCallback( (callback: MeasureParentContainerAndCursorCallback) => { const performMeasurement = () => { - const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef}); + const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); measureContainer((x, y, width, height) => { callback({ @@ -464,7 +361,7 @@ function ReportActionItemMessageEdit({ useEffect(() => { // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. - tag.set(findNodeHandle(textInputRef.current) ?? -1); + tag.set(findNodeHandle(composerRef.current) ?? -1); }, [tag]); useFocusedInputHandler( { @@ -483,23 +380,21 @@ function ReportActionItemMessageEdit({ ); useEffect(() => { - debouncedValidateCommentMaxLength(draft, {reportID}); - }, [draft, reportID, debouncedValidateCommentMaxLength]); + debouncedCommentMaxLengthValidation(draft); + }, [draft, debouncedCommentMaxLengthValidation]); useEffect(() => { - // required for keeping last state of isFocused variable - isFocusedRef.current = isFocused; - - if (!isFocused) { - hideSuggestionMenu(); + if (isFocused) { + return; } - }, [isFocused, hideSuggestionMenu]); - const closeButtonStyles = [styles.composerSizeButton, {marginVertical: styles.composerSizeButton.marginHorizontal}]; + hideSuggestionMenu(); + }, [isFocused, hideSuggestionMenu]); return ( <> @@ -509,35 +404,19 @@ function ReportActionItemMessageEdit({ styles.flexRow, styles.flex1, styles.chatItemComposeBox, - hasExceededMaxCommentLength && styles.borderColorDanger, + isExceedingMaxLength && styles.borderColorDanger, ]} > - - - e.preventDefault()} - sentryLabel={CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_CANCEL_BUTTON} - > - - - - + { - textInputRef.current = el; + ref={(el) => { + composerRef.current = el; if (typeof ref === 'function') { ref(el); } else if (ref) { @@ -553,8 +432,8 @@ function ReportActionItemMessageEdit({ style={[styles.textInputCompose, styles.flex1, styles.bgTransparent, styles.textAlignLeft]} onFocus={() => { setIsFocused(true); - if (textInputRef.current) { - ReportActionComposeFocusManager.editComposerRef.current = textInputRef.current; + if (composerRef.current) { + ReportActionComposeFocusManager.editComposerRef.current = composerRef.current; } startScrollBlock(); InteractionManager.runAfterInteractions(() => { @@ -566,9 +445,6 @@ function ReportActionItemMessageEdit({ if (isMobileChrome() && reportScrollManager.ref?.current) { reportScrollManager.ref.current.scrollToIndex({index, animated: false}); } - setShouldShowComposeInputKeyboardAware(false); - // The last composer that had focus should re-gain focus - setUpComposeFocusManager(); // Clear active report action when another action gets focused if (!isEmojiPickerActive(action.reportActionID)) { @@ -578,14 +454,7 @@ function ReportActionItemMessageEdit({ ReportActionContextMenu.clearActiveReportAction(); } }} - onBlur={(event: BlurEvent) => { - setIsFocused(false); - const relatedTargetId = event.nativeEvent?.target; - if (relatedTargetId === tag.get() || isEmojiPickerVisible()) { - return; - } - setShouldShowComposeInputKeyboardAware(true); - }} + onBlur={() => setIsFocused(false)} onLayout={(event) => { if (!isFocused) { return; @@ -597,13 +466,13 @@ function ReportActionItemMessageEdit({ isGroupPolicyReport={isGroupPolicyReport} shouldCalculateCaretPosition onScroll={onSaveScrollAndHideSuggestionMenu} - testID="composer" + testID={CONST.COMPOSER.NATIVE_ID} /> - - e.preventDefault()} - sentryLabel={CONST.SENTRY_LABEL.REPORT.REPORT_ACTION_ITEM_MESSAGE_EDIT_SAVE_BUTTON} - > - - - + publishDraft(draft)} + accessibilityLabel={translate('common.saveChanges')} + role={CONST.ROLE.BUTTON} + hoverDimmingValue={1} + pressDimmingValue={0.2} + onMouseDown={(e) => e.preventDefault()} + /> - {hasExceededMaxCommentLength && } + {isExceedingMaxLength && !!exceededMaxLength && } ); } diff --git a/src/pages/inbox/report/ReportActionItemParentAction.tsx b/src/pages/inbox/report/ReportActionItemParentAction.tsx index dfd306bf5704..291007cc1710 100644 --- a/src/pages/inbox/report/ReportActionItemParentAction.tsx +++ b/src/pages/inbox/report/ReportActionItemParentAction.tsx @@ -1,5 +1,4 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; -import {getReportActionsForReportIDs} from '@selectors/ReportAction'; import React, {useCallback} from 'react'; import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -15,14 +14,13 @@ import {getOriginalMessage, isMoneyRequestAction, isTripPreview} from '@libs/Rep import { canCurrentUserOpenReport, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - getOriginalReportID, isArchivedReport, navigateToLinkedReportAction, shouldExcludeAncestorReportAction, } from '@libs/ReportUtils'; import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Report, ReportAction, ReportActions, ReportActionsDrafts, ReportNameValuePairs, Transaction} from '@src/types/onyx'; +import type {PersonalDetailsList, Report, ReportAction, ReportNameValuePairs, Transaction} from '@src/types/onyx'; import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; import RepliesDivider from './RepliesDivider'; import ReportActionItem from './ReportActionItem'; @@ -125,44 +123,6 @@ function ReportActionItemParentAction({ [ancestors], ); - const ancestorReportActionsSelector = useCallback( - (allReportActions: OnyxCollection) => { - const reportIDs = ancestors.map((ancestor) => ancestor.report.reportID); - return getReportActionsForReportIDs(allReportActions, reportIDs); - }, - [ancestors], - ); - - const [ancestorsReportActions] = useOnyx( - ONYXKEYS.COLLECTION.REPORT_ACTIONS, - { - selector: ancestorReportActionsSelector, - }, - [ancestors], - ); - - const ancestorDraftSelector = useCallback( - (allDrafts: OnyxCollection) => { - if (!allDrafts) { - return {}; - } - const result: OnyxCollection = {}; - for (const ancestor of ancestors) { - const origID = getOriginalReportID( - ancestor.report.reportID, - ancestor.reportAction, - ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestor.report.reportID}`], - ); - const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${origID}`; - result[key] = allDrafts[key]; - } - return result; - }, - [ancestors, ancestorsReportActions], - ); - - const [ancestorDraftMessages] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {selector: ancestorDraftSelector}, [ancestors, ancestorsReportActions]); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); @@ -184,15 +144,6 @@ function ReportActionItemParentAction({ const shouldDisplayThreadDivider = !isTripPreview(ancestorReportAction); const isAncestorReportArchived = isArchivedReport(ancestorsReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${ancestorReport.reportID}`]); - const originalReportID = getOriginalReportID( - ancestorReport.reportID, - ancestorReportAction, - ancestorsReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`], - ); - const reportDraftMessages = originalReportID ? ancestorDraftMessages?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`] : undefined; - const matchingDraftMessage = reportDraftMessages?.[ancestorReportAction.reportActionID]; - const matchingDraftMessageString = matchingDraftMessage?.message; - return ( ) => { - const originalReportID = getOriginalReportID(report.reportID, reportAction, reportActionsFromOnyx); - // Use the action's actual index in sortedVisibleReportActions rather than the FlashList-provided index, // because useFlashListScrollKey may slice the data for deep-link scroll positioning, making the // FlashList index offset from the full array and causing wrong displayAsGroup computation. @@ -798,7 +794,6 @@ function ReportActionsList({ isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} shouldUseThreadDividerLine={shouldUseThreadDividerLine} personalDetails={personalDetailsList} - originalReportID={originalReportID} isReportArchived={isReportArchived} userBillingFundID={userBillingFundID} isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} @@ -816,28 +811,27 @@ function ReportActionsList({ ); }, [ - parentReportAction, - parentReportActionForTransactionThread, - report, - isOffline, - transactionThreadReport, - linkedReportActionID, actionIndexMap, - renderedVisibleReportActions, - shouldHideThreadDividerLine, - unreadMarkerReportActionID, firstVisibleReportActionID, - shouldUseThreadDividerLine, - personalDetailsList, - userBillingFundID, - isTryNewDotNVPDismissed, + hasPreviousMessages, + isOffline, isReportArchived, + isTryNewDotNVPDismissed, + linkedReportActionID, + onShowPreviousMessages, + parentReportAction, + parentReportActionForTransactionThread, + personalDetailsList, + renderedVisibleReportActions, + report, reportNameValuePairs?.origin, reportNameValuePairs?.originalID, - reportActionsFromOnyx, + shouldHideThreadDividerLine, + shouldUseThreadDividerLine, showHiddenHistory, - hasPreviousMessages, - onShowPreviousMessages, + transactionThreadReport, + unreadMarkerReportActionID, + userBillingFundID, ], ); diff --git a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx index a08b7ba8281b..253315e0d75e 100644 --- a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx @@ -1,10 +1,8 @@ import React, {memo, useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import useOnyx from '@hooks/useOnyx'; import {getOriginalMessage, isSentMoneyReportAction, isTransactionThread} from '@libs/ReportActionsUtils'; import {isChatThread} from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Report, ReportAction} from '@src/types/onyx'; import ReportActionItem from './ReportActionItem'; import ReportActionItemParentAction from './ReportActionItemParentAction'; @@ -52,9 +50,6 @@ type ReportActionsListItemRendererProps = { /** Animate highlight action in few seconds */ shouldHighlight?: boolean; - /** The original report ID for draft message lookups */ - originalReportID: string | undefined; - /** Personal details list */ personalDetails: OnyxEntry; @@ -88,7 +83,6 @@ function ReportActionsListItemRenderer({ shouldUseThreadDividerLine = false, shouldHighlight = false, parentReportActionForTransactionThread, - originalReportID, userBillingFundID, personalDetails, isTryNewDotNVPDismissed = false, @@ -98,9 +92,6 @@ function ReportActionsListItemRenderer({ }: ReportActionsListItemRendererProps) { const originalMessage = useMemo(() => getOriginalMessage(reportAction), [reportAction]); - const [reportDraftMessages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`); - const draftMessage = reportDraftMessages?.[reportAction.reportActionID]?.message; - /** * Create a lightweight ReportAction so as to keep the re-rendering as light as possible by * passing in only the required props. @@ -211,7 +202,6 @@ function ReportActionsListItemRenderer({ shouldUseThreadDividerLine={shouldUseThreadDividerLine} shouldHighlight={shouldHighlight} personalDetails={personalDetails} - draftMessage={draftMessage} userBillingFundID={userBillingFundID} isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} reportNameValuePairsOrigin={reportNameValuePairsOrigin} diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index adb8efdfa270..9dad6f68ed7b 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -48,17 +48,15 @@ function ReportFooter() { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- isSmallScreenWidth guards composer visibility on mobile during keyboard events, shouldUseNarrowLayout would wrongly hide it in RHP - const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lightbulb']); - const isAnonymousUser = useIsAnonymousUser(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); const isReportArchived = useReportIsArchived(report?.reportID); const {isCurrentReportLoadedFromOnyx} = useIsReportReadyToDisplay(report, reportIDFromRoute, isReportArchived); - const [shouldShowComposeInput = false] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); + const isAnonymousUser = useIsAnonymousUser(); const [isBlockedFromChat] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CHAT, { selector: isBlockedFromChatSelector, }); @@ -87,7 +85,7 @@ function ReportFooter() { const chatFooterStyles = {...styles.chatFooter, minHeight: !isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0}; // Happy path — user can compose - if (!shouldHideComposer && (shouldShowComposeInput || !isSmallScreenWidth)) { + if (!shouldHideComposer) { return ( diff --git a/src/pages/inbox/report/actionContents/ChatMessageContent.tsx b/src/pages/inbox/report/actionContents/ChatMessageContent.tsx index 61fe82f34478..f3a646254ea2 100644 --- a/src/pages/inbox/report/actionContents/ChatMessageContent.tsx +++ b/src/pages/inbox/report/actionContents/ChatMessageContent.tsx @@ -8,6 +8,7 @@ import {useBlockedFromConcierge} from '@components/OnyxListItemProvider'; import {ShowContextMenuActionsContext, ShowContextMenuStateContext} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {parseFollowupsFromHtml} from '@libs/ReportActionFollowupUtils'; import { @@ -77,6 +78,8 @@ function ChatMessageContent({ const styles = useThemeStyles(); const blockedFromConcierge = useBlockedFromConcierge(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isEditingInline = !shouldUseNarrowLayout && draftMessage !== undefined; const mentionReportContextValue = {currentReportID: report?.reportID, exactlyMatch: true}; @@ -97,7 +100,19 @@ function ChatMessageContent({ - {draftMessage === undefined ? ( + {isEditingInline ? ( + + ) : ( )} - ) : ( - )} diff --git a/src/pages/inbox/report/useActiveDraftReportAction.ts b/src/pages/inbox/report/useActiveDraftReportAction.ts new file mode 100644 index 000000000000..b1c1657c6467 --- /dev/null +++ b/src/pages/inbox/report/useActiveDraftReportAction.ts @@ -0,0 +1,215 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import useAncestors from '@hooks/useAncestors'; +import useOnyx from '@hooks/useOnyx'; +import {getOriginalReportID, shouldExcludeAncestorReportAction} from '@libs/ReportUtils'; +import type {Ancestor} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +/** + * Canonical active edit derived from drafts on an ancestor-original report, the visible report, or a linked transaction thread. + */ +type ResolvedActiveDraftEdit = { + editingReportID: string | null; + editingReportActionID: string; + editingReportAction: OnyxTypes.ReportAction | null; + draftMessage: string; +}; + +type UseActiveDraftReportActionArgs = { + reportID: string | undefined; + effectiveTransactionThreadReportID?: string; +}; + +/** When any ancestor carries a persisted draft message for its surfaced action (newest-in-chain wins first in priority). */ +function findAncestorWithDraftOnChain(params: { + ancestors: Ancestor[]; + reportActions: OnyxCollection | undefined; + reportActionsDrafts: OnyxCollection | undefined; +}) { + const {ancestors, reportActions, reportActionsDrafts} = params; + + const ancestorChainReversedNewestFirst = [...ancestors].slice().reverse(); + + return ancestorChainReversedNewestFirst.find(({report: ancestorReport, reportAction}) => { + const reportActionsForAncestor = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`]; + const draftOwnerReportID = getOriginalReportID(ancestorReport.reportID, reportAction, reportActionsForAncestor); + if (!draftOwnerReportID) { + return false; + } + + const draftBucketForOwner = reportActionsDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${draftOwnerReportID}`]; + const draftPayload = draftBucketForOwner?.[reportAction.reportActionID]; + + return draftPayload?.message !== undefined; + }); +} + +/** Pure resolution from scoped Onyx slices (runs each render once snapshots are fresh). */ +function computeResolvedActiveDraftEdit(inputs: { + ancestors: Ancestor[]; + reportActions: OnyxCollection | undefined; + reportActionsDrafts: OnyxCollection | undefined; + reportID: string | undefined; + transactionThreadReportID: string | undefined; +}): ResolvedActiveDraftEdit | null { + const {ancestors, reportActions, reportActionsDrafts, reportID, transactionThreadReportID} = inputs; + + const ancestorWithDraft = findAncestorWithDraftOnChain({ancestors, reportActions, reportActionsDrafts}); + + if (ancestorWithDraft != null) { + const {report: ancestorReport, reportAction: ancestorReportAction} = ancestorWithDraft; + const actionsRowForAncestor = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestorReport.reportID}`]; + const draftOwningReportID = getOriginalReportID(ancestorReport.reportID, ancestorReportAction, actionsRowForAncestor); + const draftsForOwningReport = draftOwningReportID != null ? reportActionsDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${draftOwningReportID}`] : undefined; + const persistedDraftPayload = draftsForOwningReport?.[ancestorReportAction.reportActionID]; + + const nextMessage = persistedDraftPayload?.message ?? null; + if (nextMessage == null) { + return null; + } + + return { + editingReportID: ancestorReport.reportID, + editingReportActionID: ancestorReportAction.reportActionID, + editingReportAction: ancestorReportAction, + draftMessage: nextMessage, + }; + } + + const draftsBucketForVisibleReport = reportActionsDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`]; + const draftEntryOnVisibleReport = Object.entries(draftsBucketForVisibleReport ?? {}).find(([, draft]) => draft?.message !== undefined); + + if (draftEntryOnVisibleReport != null) { + const [reportActionIDCarryingDraft, reportActionDraftPersisted] = draftEntryOnVisibleReport; + const nextMessageFromVisibleDraft = reportActionDraftPersisted?.message ?? null; + if (nextMessageFromVisibleDraft === null) { + return null; + } + + const reportActionsMapForVisibleReport = reportID != null ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] : undefined; + + return { + editingReportID: reportID ?? null, + editingReportActionID: reportActionIDCarryingDraft, + editingReportAction: reportActionsMapForVisibleReport?.[reportActionIDCarryingDraft] ?? null, + draftMessage: nextMessageFromVisibleDraft, + }; + } + + if (transactionThreadReportID == null) { + return null; + } + + const draftsOnTransactionThread = reportActionsDrafts?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${transactionThreadReportID}`]; + const transactionThreadDraftEntry = Object.entries(draftsOnTransactionThread ?? {}).find(([, draftRow]) => draftRow?.message !== undefined); + + const [threadDraftReportActionID, threadPersistedDraft] = transactionThreadDraftEntry ?? [undefined, undefined]; + + if (threadDraftReportActionID == null || threadPersistedDraft?.message === undefined) { + return null; + } + + const actionsMapForTransactionThread = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`]; + const hydratedThreadReportAction = actionsMapForTransactionThread?.[threadDraftReportActionID]; + + if (hydratedThreadReportAction == null) { + return null; + } + + return { + editingReportID: transactionThreadReportID, + editingReportActionID: threadDraftReportActionID, + editingReportAction: hydratedThreadReportAction, + draftMessage: threadPersistedDraft.message, + }; +} + +/** + * Narrow Onyx subscriptions and resolve which single report/action (if any) has edit draft state. + * + * Preference order matches the provider: ancestor chain first, visible report draft, transaction-thread draft. + */ +function useActiveDraftReportAction({reportID, effectiveTransactionThreadReportID}: UseActiveDraftReportActionArgs): ResolvedActiveDraftEdit | null { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const ancestors = useAncestors(report, shouldExcludeAncestorReportAction); + + const transactionThreadReportID = + !effectiveTransactionThreadReportID || effectiveTransactionThreadReportID === CONST.FAKE_REPORT_ID || effectiveTransactionThreadReportID === reportID + ? undefined + : effectiveTransactionThreadReportID; + + const [reportActions] = useOnyx( + ONYXKEYS.COLLECTION.REPORT_ACTIONS, + { + selector: (allReportActions) => { + if (!allReportActions) { + return {}; + } + + const scopedReportActionsSlice: OnyxCollection = {}; + + if (reportID) { + const visibleReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; + scopedReportActionsSlice[visibleReportActionsKey] = allReportActions[visibleReportActionsKey]; + } + + if (transactionThreadReportID != null) { + const transactionThreadActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`; + scopedReportActionsSlice[transactionThreadActionsKey] = allReportActions[transactionThreadActionsKey]; + } + + for (const ancestor of ancestors) { + const ancestorReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestor.report.reportID}`; + scopedReportActionsSlice[ancestorReportActionsKey] = allReportActions[ancestorReportActionsKey]; + } + + return scopedReportActionsSlice; + }, + }, + [ancestors, reportID, transactionThreadReportID], + ); + + const [reportActionsDrafts] = useOnyx( + ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, + { + selector: (allDrafts) => { + if (!allDrafts) { + return {}; + } + + const scopedDraftsSlice: OnyxCollection = {}; + + if (reportID) { + const currentDraftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`; + scopedDraftsSlice[currentDraftKey] = allDrafts[currentDraftKey]; + } + + if (transactionThreadReportID != null) { + const transactionThreadDraftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${transactionThreadReportID}`; + scopedDraftsSlice[transactionThreadDraftKey] = allDrafts[transactionThreadDraftKey]; + } + + for (const ancestor of ancestors) { + const actionsForAncestorRow = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ancestor.report.reportID}`]; + const draftOwningReportID = getOriginalReportID(ancestor.report.reportID, ancestor.reportAction, actionsForAncestorRow); + if (!draftOwningReportID) { + continue; + } + const ancestorDraftBucketKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${draftOwningReportID}`; + scopedDraftsSlice[ancestorDraftBucketKey] = allDrafts[ancestorDraftBucketKey]; + } + + return scopedDraftsSlice; + }, + }, + [ancestors, reportActions, reportID, transactionThreadReportID], + ); + + return computeResolvedActiveDraftEdit({ancestors, reportActions, reportActionsDrafts, reportID, transactionThreadReportID}); +} + +export default useActiveDraftReportAction; + +export type {ResolvedActiveDraftEdit}; diff --git a/src/pages/inbox/report/useClearReportActionDraftsOnReportChange.ts b/src/pages/inbox/report/useClearReportActionDraftsOnReportChange.ts new file mode 100644 index 000000000000..7ca7d3dd7ec0 --- /dev/null +++ b/src/pages/inbox/report/useClearReportActionDraftsOnReportChange.ts @@ -0,0 +1,15 @@ +import {useEffect} from 'react'; +import {clearAllReportActionDrafts} from '@libs/actions/Report'; + +// When the report screen is navigated away from or the report changes, clear all report action edit drafts +function useClearReportActionDraftsOnReportChange(reportID: string | undefined) { + useEffect(() => { + clearAllReportActionDrafts(); + + return () => { + clearAllReportActionDrafts(); + }; + }, [reportID]); +} + +export default useClearReportActionDraftsOnReportChange; diff --git a/src/pages/inbox/report/useDebouncedSaveDraft.ts b/src/pages/inbox/report/useDebouncedSaveDraft.ts new file mode 100644 index 000000000000..c78c1969f48c --- /dev/null +++ b/src/pages/inbox/report/useDebouncedSaveDraft.ts @@ -0,0 +1,40 @@ +import {useEffect, useRef} from 'react'; +import useDebounce from '@hooks/useDebounce'; +import CONST from '@src/CONST'; + +/** + * Debounces a function to save a draft for a report comment or report action draft. + * @param saveDraft - The function to save the draft. It will be called with the arguments passed to the triggerSaveDraft function. + * @returns An object containing the debounced save draft function, the trigger save draft function, and the is save pending ref. + * @property {Function} debouncedSaveDraft - The debounced save draft function. + * @property {Function} triggerSaveDraft - The trigger save draft function. + * @property {Ref} isSavePending - The ref to check whether the save is pending. + */ +function useDebouncedSaveDraft(saveDraftFn: (...args: SaveDraftArgs) => void, wait = CONST.TIMING.DRAFT_SAVE_DEBOUNCE_TIME) { + const isSavePending = useRef(false); + + const debouncedSaveDraft = useDebounce((...args: SaveDraftArgs) => { + saveDraftFn(...args); + isSavePending.current = false; + }, wait); + + const saveDraft = (...args: SaveDraftArgs) => { + isSavePending.current = true; + debouncedSaveDraft(...args); + }; + + // Cancel the debounced save draft on unmount + useEffect( + () => () => { + isSavePending.current = false; + }, + [debouncedSaveDraft], + ); + + return { + saveDraft, + isSavePending, + }; +} + +export default useDebouncedSaveDraft; diff --git a/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts b/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts new file mode 100644 index 000000000000..8124ec8b2378 --- /dev/null +++ b/src/pages/inbox/report/useDraftMessageVideoAttributeCache.ts @@ -0,0 +1,53 @@ +import type React from 'react'; +import {useEffect} from 'react'; +import usePrevious from '@hooks/usePrevious'; +import Parser from '@libs/Parser'; +import {getReportActionHtml, isDeletedAction} from '@libs/ReportActionsUtils'; +import type * as OnyxTypes from '@src/types/onyx'; + +type DraftMessageVideoAttributeCache = Map; + +const draftMessageVideoAttributeCache: DraftMessageVideoAttributeCache = new Map(); +type UseDraftMessageVideoAttributeCacheProps = { + draftMessage: string; + isEditing: boolean; + editingReportAction: OnyxTypes.ReportAction | null; + updateDraftMessage: (draftMessage: string) => void; + isEditInProgressRef: React.RefObject; +}; + +function useDraftMessageVideoAttributeCache({ + draftMessage, + isEditing = false, + editingReportAction = null, + updateDraftMessage: updateDraftMessageProp = () => {}, + isEditInProgressRef, +}: UseDraftMessageVideoAttributeCacheProps): DraftMessageVideoAttributeCache { + const prevDraftMessage = usePrevious(draftMessage); + + useEffect(() => { + if (!isEditing) { + return; + } + + draftMessageVideoAttributeCache.clear(); + + const originalMessage = Parser.htmlToMarkdown(getReportActionHtml(editingReportAction), { + cacheVideoAttributes: (videoSource, attrs) => draftMessageVideoAttributeCache.set(videoSource, attrs), + }); + if ( + isDeletedAction(editingReportAction) || + !!(editingReportAction?.message && draftMessage === originalMessage) || + !!(prevDraftMessage === draftMessage || isEditInProgressRef.current) + ) { + return; + } + updateDraftMessageProp(draftMessage); + }, [draftMessage, editingReportAction, isEditInProgressRef, isEditing, prevDraftMessage, updateDraftMessageProp]); + + return draftMessageVideoAttributeCache; +} + +export default useDraftMessageVideoAttributeCache; +export {draftMessageVideoAttributeCache}; +export type {DraftMessageVideoAttributeCache}; diff --git a/src/setup/index.ts b/src/setup/index.ts index 844b85eee059..2bde36a8d857 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -46,7 +46,6 @@ export default function () { [ONYXKEYS.SESSION]: {loading: false}, [ONYXKEYS.ACCOUNT]: CONST.DEFAULT_ACCOUNT_DATA, [ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED]: false, - [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: true, [ONYXKEYS.MODAL]: { isVisible: false, willAlertModalBecomeVisible: false, diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx index b4cf986ceb14..28306dda484c 100644 --- a/tests/perf-test/ReportActionCompose.perf-test.tsx +++ b/tests/perf-test/ReportActionCompose.perf-test.tsx @@ -3,14 +3,14 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import type Animated from 'react-native-reanimated'; import {measureRenders} from 'reassure'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {KeyboardStateProvider} from '@components/withKeyboardState'; import type {EmojiPickerRef} from '@libs/actions/EmojiPickerAction'; import type Navigation from '@libs/Navigation/Navigation'; import {setHasRadio} from '@libs/NetworkState'; import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; -import ComposeProviders from '@src/components/ComposeProviders'; -import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; -import {KeyboardStateProvider} from '@src/components/withKeyboardState'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; @@ -86,6 +86,8 @@ beforeEach(() => { } as Report); }); +const mockEvent = {preventDefault: jest.fn()}; + function ReportActionComposeWrapper() { return ( @@ -93,7 +95,6 @@ function ReportActionComposeWrapper() { ); } -const mockEvent = {preventDefault: jest.fn()}; test('[ReportActionCompose] should render Composer with text input interactions', async () => { const scenario = async () => { diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index bff7a59086b8..fa97b5782f48 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -1,12 +1,15 @@ import type * as NativeNavigation from '@react-navigation/native'; import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import React from 'react'; +import type {PropsWithChildren} from 'react'; import Onyx from 'react-native-onyx'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {KeyboardStateProvider} from '@components/withKeyboardState'; import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import * as LHNTestUtils from '../utils/LHNTestUtils'; @@ -56,14 +59,22 @@ const defaultProps: ReportActionComposeProps = { reportID: defaultReport.reportID, }; +function ReportActionEditMessageContextProviderForReport({children}: PropsWithChildren) { + return {children}; +} + +function ReportScreenProviders({children}: PropsWithChildren) { + return {children}; +} + const renderReportActionCompose = (props?: Partial) => { return render( - + - , + , ); }; @@ -399,5 +410,25 @@ describe('ReportActionCompose Integration Tests', () => { unmount(); }); + + it('should not send when task title length exceeds the limit', async () => { + const {unmount} = renderReportActionCompose(); + const composer = screen.getByTestId('composer'); + + // Given a task title that exceeds the title character limit + const taskTitle = 'x'.repeat(CONST.TITLE_CHARACTER_LIMIT + 1); + fireEvent.changeText(composer, `[] ${taskTitle}`); + + // The debounced validation fires on the trailing edge after COMMENT_LENGTH_DEBOUNCE_TIME + await waitFor( + () => { + // And the task-title-specific error should be displayed + expect(screen.getByText('composer.taskTitleExceededMaxLength')).toBeOnTheScreen(); + }, + {timeout: CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME + 500}, + ); + + unmount(); + }); }); }); diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index c505be3cf7ff..589e6513aa1f 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -1,19 +1,25 @@ import type * as NativeNavigation from '@react-navigation/native'; import {act, fireEvent, render, screen} from '@testing-library/react-native'; -import React from 'react'; +import type {PropsWithChildren} from 'react'; import Onyx from 'react-native-onyx'; import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {KeyboardStateProvider} from '@components/withKeyboardState'; import {editReportComment} from '@libs/actions/Report'; -import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; +import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; +import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; +import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Message} from '@src/types/onyx/ReportAction'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; const mockEditReportComment = jest.mocked(editReportComment); +const mockShowDeleteModal = jest.mocked(ReportActionContextMenu.showDeleteModal); jest.mock('@libs/actions/Report', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -25,6 +31,16 @@ jest.mock('@libs/actions/Report', () => { }; }); +jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@pages/inbox/report/ContextMenu/ReportActionContextMenu'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + showDeleteModal: jest.fn(), + }; +}); + jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn((key: string) => key), @@ -49,23 +65,31 @@ jest.mock('@react-navigation/native', () => ({ TestHelper.setupGlobalFetchMock(); const defaultReport = LHNTestUtils.getFakeReport(); +const defaultReportAction = LHNTestUtils.getFakeReportAction(); const defaultProps: ReportActionItemMessageEditProps = { - action: LHNTestUtils.getFakeReportAction(), - draftMessage: '', + action: defaultReportAction, reportID: defaultReport.reportID, originalReportID: defaultReport.reportID, index: 0, isGroupPolicyReport: false, }; +function ReportActionEditMessageContextProviderForReport({children}: PropsWithChildren) { + return {children}; +} + +function ReportScreenProviders({children}: PropsWithChildren) { + return {children}; +} + const renderReportActionItemMessageEdit = (props?: Partial) => { return render( - + - , + , ); }; @@ -81,6 +105,7 @@ describe('ReportActionCompose Integration Tests', () => { await act(async () => { await Onyx.clear(); }); + draftMessageVideoAttributeCache.clear(); jest.clearAllMocks(); }); @@ -119,5 +144,64 @@ describe('ReportActionCompose Integration Tests', () => { // And the error should be displayed expect(screen.getByText('composer.commentExceededMaxLength')).toBeOnTheScreen(); }); + + it('should open delete modal when saving an empty message', async () => { + renderReportActionItemMessageEdit(); + const composer = screen.getByTestId('composer'); + const saveChangesButton = screen.getByLabelText('common.saveChanges'); + + // Given a message that becomes empty after trimming + fireEvent.changeText(composer, ' '); + + // When the message is saved + fireEvent.press(saveChangesButton); + + // Then the message should NOT be edited + expect(mockEditReportComment).toHaveBeenCalledTimes(0); + + // And the delete confirmation flow should be opened + expect(mockShowDeleteModal).toHaveBeenCalledTimes(1); + }); + + it('should cache and forward video attributes when saving an edited message', async () => { + const videoSource = 'https://example.com/video.mp4'; + const videoHtml = ``; + + const messages = defaultReportAction.message as Message[]; + + renderReportActionItemMessageEdit({ + action: { + ...defaultReportAction, + message: [ + { + ...messages.at(0), + type: 'COMMENT', + html: videoHtml, + text: '[Attachment]', + }, + ], + }, + }); + + const composer = screen.getByTestId('composer'); + const saveChangesButton = screen.getByLabelText('common.saveChanges'); + + // Given a valid edited message + fireEvent.changeText(composer, 'Edited message'); + + // When the message is saved + fireEvent.press(saveChangesButton); + + expect(mockEditReportComment).toHaveBeenCalledTimes(1); + + const editReportCommentArgs = mockEditReportComment.mock.calls.at(0); + const videoAttributeCache = editReportCommentArgs?.[7]; + + expect(videoAttributeCache).toEqual(expect.any(Object)); + expect(videoAttributeCache?.[videoSource]).toEqual(expect.any(String)); + expect(videoAttributeCache?.[videoSource]).toContain('data-name'); + expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-height'); + expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-width'); + }); }); }); diff --git a/tests/ui/ReportActionMessageEditLayoutTest.tsx b/tests/ui/ReportActionMessageEditLayoutTest.tsx new file mode 100644 index 000000000000..c3fbb0b6d199 --- /dev/null +++ b/tests/ui/ReportActionMessageEditLayoutTest.tsx @@ -0,0 +1,394 @@ +import type * as NativeNavigation from '@react-navigation/native'; +import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react-native'; +import React from 'react'; +import type {PropsWithChildren} from 'react'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {KeyboardStateProvider} from '@components/withKeyboardState'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; +import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; +import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; +import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +/** + * Exercises where message edit appears on narrow (main @ReportActionCompose) vs wide (@ReportActionItemMessageEdit), + * draft set/unset, and layout switching. TestIDs: CONST.COMPOSER.TEST_ID.* + */ + +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const narrowLayout: ReturnType = { + shouldUseNarrowLayout: true, + isSmallScreenWidth: true, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + onboardingIsMediumOrLargerScreenWidth: false, + isLargeScreenWidth: false, + isSmallScreen: true, +} as ReturnType; + +const wideLayout: ReturnType = { + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + onboardingIsMediumOrLargerScreenWidth: true, + isLargeScreenWidth: true, + isSmallScreen: false, +} as ReturnType; + +jest.mock('@libs/getPlatform', () => ({ + __esModule: true, + default: () => 'web', +})); + +jest.mock('@libs/ComponentUtils', () => ({ + forceClearInput: jest.fn(), +})); + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn((key: string) => key), + numberFormat: jest.fn((num: number) => num.toString()), + })), +); + +jest.mock('@hooks/usePaginatedReportActions', () => jest.fn(() => ({reportActions: [], hasNewerActions: false, hasOlderActions: false}))); +jest.mock('@hooks/useParentReportAction', () => jest.fn(() => null)); +jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn(() => ({}))); +jest.mock('@hooks/useShortMentionsList', () => jest.fn(() => ({availableLoginsList: []}))); +jest.mock('@hooks/useSidePanelState', () => jest.fn(() => ({sessionStartTime: null}))); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +jest.mock('@libs/actions/Report', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + editReportComment: jest.fn(), + }; +}); + +jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@pages/inbox/report/ContextMenu/ReportActionContextMenu'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + showDeleteModal: jest.fn(), + }; +}); + +jest.mock('@components/DropZone/DualDropZone', () => { + const RN = jest.requireActual>>('react-native'); + return ({shouldAcceptSingleReceipt}: {shouldAcceptSingleReceipt?: boolean}) => ( + {shouldAcceptSingleReceipt ? 'receipt-editable' : 'receipt-not-editable'} + ); +}); + +const mockRouteReportID = {current: '1'}; + +jest.mock('@react-navigation/native', () => ({ + ...((): typeof NativeNavigation => { + return jest.requireActual('@react-navigation/native'); + })(), + useNavigation: jest.fn(() => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + })), + useIsFocused: jest.fn(() => true), + useRoute: jest.fn(() => ({key: '', name: '', params: {reportID: mockRouteReportID.current}})), +})); + +TestHelper.setupGlobalFetchMock(); + +const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); + +const defaultReport = LHNTestUtils.getFakeReport(); +mockRouteReportID.current = defaultReport.reportID; + +const defaultProps: ReportActionComposeProps = { + reportID: defaultReport.reportID, +}; + +const commentAction: ReportActionItemMessageEditProps['action'] = { + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, +}; + +const testIds = CONST.COMPOSER.TEST_ID; + +function ReportActionEditMessageContextProviderForReport({children}: PropsWithChildren) { + return {children}; +} + +function ReportScreenProviders({children}: PropsWithChildren) { + return {children}; +} + +/** + * Simulates the product split: on wide, inline @ReportActionItemMessageEdit is mounted (isEditingInline in PureReportActionItem); + * on narrow it is not and edit happens in the main composer. + */ +type LayoutMode = 'narrow' | 'wide'; +function MessageEditLayoutHost({layout}: {layout: LayoutMode}) { + const isWide = layout === 'wide'; + return ( + + + {isWide && ( + + )} + + ); +} + +async function seedReportAndActions() { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${defaultReport.reportID}`, defaultReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${defaultReport.reportID}`, { + [commentAction.reportActionID]: commentAction, + }); + }); +} + +async function setReportActionDraftWithMessage(message: string) { + await act(async () => { + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${defaultReport.reportID}`]: { + [commentAction.reportActionID]: {message}, + }, + }); + }); +} + +async function clearReportActionDraftsForReport() { + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${defaultReport.reportID}`, {}); + }); +} + +function renderNarrowMessageCompose() { + mockUseResponsiveLayout.mockReturnValue(narrowLayout); + return render( + + + , + ); +} + +describe('ReportActionMessageEdit layout and draft (narrow vs wide)', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + }); + + beforeEach(() => { + mockUseResponsiveLayout.mockReturnValue(narrowLayout); + jest.useFakeTimers(); + }); + + afterEach(async () => { + jest.useRealTimers(); + await act(async () => { + await Onyx.clear(); + }); + draftMessageVideoAttributeCache.clear(); + }); + + it('with no report-action draft, main composer is in normal draft message mode (not message-edit action row)', async () => { + await seedReportAndActions(); + await waitForBatchedUpdatesWithAct(); + + renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(screen.queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); + }); + + it('when a report-action draft is set on narrow, main composer enters message edit mode and edit-mode test IDs are used', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Narrow body'); + await waitForBatchedUpdatesWithAct(); + + renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(screen.queryByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeNull(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)).toBeOnTheScreen(); + const mainCompose = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); + expect(within(mainCompose).getByTestId(CONST.COMPOSER.NATIVE_ID).props.value).toBe('Narrow body'); + }); + + it('when the draft is cleared, message edit mode ends and normal draft action row returns', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Then remove'); + await waitForBatchedUpdatesWithAct(); + + const {unmount} = renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + + await clearReportActionDraftsForReport(); + await waitForBatchedUpdatesWithAct(); + + unmount(); + renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(screen.queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); + }); + + it('on wide, main composer stays in normal action row while the inline @ReportActionItemMessageEdit is used', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Wide inline'); + await waitForBatchedUpdatesWithAct(); + + mockUseResponsiveLayout.mockReturnValue(wideLayout); + render(); + await waitForBatchedUpdatesWithAct(); + + const mainRoot = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); + expect(within(mainRoot).getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(within(mainRoot).queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); + + expect(screen.getByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeOnTheScreen(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_INLINE)).toBeOnTheScreen(); + expect(screen.queryByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)).toBeNull(); + }); + + it('switches the editing surface from inline (wide) to main composer (narrow) when layout becomes narrow', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Shared draft'); + await waitForBatchedUpdatesWithAct(); + + mockUseResponsiveLayout.mockReturnValue(wideLayout); + const {unmount} = render( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeOnTheScreen(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_INLINE)).toBeOnTheScreen(); + const mainWide = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); + expect(within(mainWide).getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + + unmount(); + mockUseResponsiveLayout.mockReturnValue(narrowLayout); + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeNull(); + expect(screen.queryByTestId(testIds.MESSAGE_EDIT_CANCEL_INLINE)).toBeNull(); + expect(screen.getByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)).toBeOnTheScreen(); + }); + + it('switches the editing surface from main composer (narrow) to inline (wide) when layout becomes wide', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Back to wide'); + await waitForBatchedUpdatesWithAct(); + + mockUseResponsiveLayout.mockReturnValue(narrowLayout); + const {unmount} = render( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)).toBeOnTheScreen(); + expect(screen.queryByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeNull(); + + unmount(); + mockUseResponsiveLayout.mockReturnValue(wideLayout); + render( + , + ); + await waitForBatchedUpdatesWithAct(); + + const main = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); + expect(within(main).getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(within(main).queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); + expect(screen.getByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeOnTheScreen(); + expect(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_INLINE)).toBeOnTheScreen(); + }); + + it('in narrow message-edit-in-composer mode, updateComment keeps the main composer value in sync (editingState + shouldUseNarrowLayout branch in ComposerWithSuggestions)', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Start'); + await waitForBatchedUpdatesWithAct(); + + mockUseResponsiveLayout.mockReturnValue(narrowLayout); + renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + + const mainRoot = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); + const composer = within(mainRoot).getByTestId(CONST.COMPOSER.NATIVE_ID); + expect(screen.getByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + + fireEvent.changeText(composer, 'Start, edited'); + await waitFor(() => { + expect(within(mainRoot).getByTestId(CONST.COMPOSER.NATIVE_ID).props.value).toBe('Start, edited'); + }); + }); + + it('cancel in narrow main composer returns to normal draft action row', async () => { + await seedReportAndActions(); + await setReportActionDraftWithMessage('Cancel me'); + await waitForBatchedUpdatesWithAct(); + + renderNarrowMessageCompose(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + }); + expect(screen.queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); + }); +}); diff --git a/tests/unit/hooks/useDebouncedCommentMaxLengthValidation.test.ts b/tests/unit/hooks/useDebouncedCommentMaxLengthValidation.test.ts new file mode 100644 index 000000000000..db004d43d90d --- /dev/null +++ b/tests/unit/hooks/useDebouncedCommentMaxLengthValidation.test.ts @@ -0,0 +1,50 @@ +import {act, renderHook, waitFor} from '@testing-library/react-native'; +import {getCommentLength} from '@libs/ReportUtils'; +import useDebouncedCommentMaxLengthValidation from '@pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation'; +import CONST from '@src/CONST'; + +jest.mock('@libs/ReportUtils', () => { + return { + getCommentLength: jest.fn(), + }; +}); + +const mockGetCommentLength = jest.mocked(getCommentLength); + +describe('useDebouncedCommentMaxLengthValidation', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should apply task-title validation when not editing and input matches task pattern', () => { + const {result} = renderHook(() => useDebouncedCommentMaxLengthValidation({reportID: '1', isEditing: false})); + + const title = 'x'.repeat(CONST.TITLE_CHARACTER_LIMIT + 1); + + act(() => { + result.current.debouncedCommentMaxLengthValidation(`[] ${title}`); + }); + + expect(result.current.isTaskTitle).toBe(true); + expect(result.current.exceededMaxLength).toBe(CONST.TITLE_CHARACTER_LIMIT); + expect(result.current.isExceedingMaxLength).toBe(true); + expect(mockGetCommentLength).not.toHaveBeenCalled(); + }); + + it('should apply comment-length validation when editing', async () => { + mockGetCommentLength.mockReturnValue(CONST.MAX_COMMENT_LENGTH + 1); + + const {result} = renderHook(() => useDebouncedCommentMaxLengthValidation({reportID: '1', isEditing: true})); + + act(() => { + result.current.debouncedCommentMaxLengthValidation('x'.repeat(CONST.MAX_COMMENT_LENGTH + 1)); + result.current.debouncedCommentMaxLengthValidation.flush(); + }); + + await waitFor(() => { + expect(result.current.isTaskTitle).toBe(false); + expect(result.current.exceededMaxLength).toBe(CONST.MAX_COMMENT_LENGTH); + expect(result.current.isExceedingMaxLength).toBe(true); + }); + }); +}); diff --git a/tests/unit/hooks/useDraftMessageVideoAttributeCache.test.ts b/tests/unit/hooks/useDraftMessageVideoAttributeCache.test.ts new file mode 100644 index 000000000000..83a04de88018 --- /dev/null +++ b/tests/unit/hooks/useDraftMessageVideoAttributeCache.test.ts @@ -0,0 +1,77 @@ +import {renderHook} from '@testing-library/react-native'; +import Parser from '@libs/Parser'; +import {getReportActionHtml} from '@libs/ReportActionsUtils'; +import useDraftMessageVideoAttributeCache, {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; + +jest.mock('@libs/ReportActionsUtils', () => ({ + getReportActionHtml: jest.fn(), + isDeletedAction: jest.fn(() => false), +})); + +const mockGetReportActionHtml = jest.mocked(getReportActionHtml); + +describe('useDraftMessageVideoAttributeCache', () => { + let htmlToMarkdownSpy: jest.SpiedFunction; + + beforeEach(() => { + htmlToMarkdownSpy = jest.spyOn(Parser, 'htmlToMarkdown'); + }); + + afterEach(() => { + draftMessageVideoAttributeCache.clear(); + jest.clearAllMocks(); + }); + + it('should cache video attributes from the original message html when editing', () => { + const reportAction = {reportActionID: '1'} as never; + mockGetReportActionHtml.mockReturnValue(''); + + htmlToMarkdownSpy.mockImplementation((_html, extras) => { + extras?.cacheVideoAttributes?.('https://example.com/video.mp4', ' data-name="v.mp4"'); + return 'original markdown'; + }); + + const updateDraftMessage = jest.fn(); + const isEditInProgressRef = {current: false}; + + renderHook(() => + useDraftMessageVideoAttributeCache({ + draftMessage: 'changed', + isEditing: true, + editingReportAction: reportAction, + updateDraftMessage, + isEditInProgressRef, + }), + ); + + expect(draftMessageVideoAttributeCache.get('https://example.com/video.mp4')).toBe(' data-name="v.mp4"'); + }); + + it('should not call updateDraftMessage when edit is in progress', () => { + const reportAction = {reportActionID: '1'} as never; + mockGetReportActionHtml.mockReturnValue(''); + htmlToMarkdownSpy.mockImplementation((_html, extras) => { + extras?.cacheVideoAttributes?.('https://example.com/video.mp4', ' data-name="v.mp4"'); + return 'original markdown'; + }); + + const updateDraftMessage = jest.fn(); + const isEditInProgressRef = {current: true}; + + const {rerender} = renderHook( + ({draftMessage}) => + useDraftMessageVideoAttributeCache({ + draftMessage, + isEditing: true, + editingReportAction: reportAction, + updateDraftMessage, + isEditInProgressRef, + }), + {initialProps: {draftMessage: 'changed'}}, + ); + + rerender({draftMessage: 'changed again'}); + + expect(updateDraftMessage).toHaveBeenCalledTimes(0); + }); +}); diff --git a/tests/unit/hooks/useEditComposerToggle.test.ts b/tests/unit/hooks/useEditComposerToggle.test.ts new file mode 100644 index 000000000000..0bd1763d4e58 --- /dev/null +++ b/tests/unit/hooks/useEditComposerToggle.test.ts @@ -0,0 +1,408 @@ +import {renderHook} from '@testing-library/react-native'; +import type {RefObject} from 'react'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; +import * as ComposerContext from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +import type {ComposerActions, ComposerEditState} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +import ReportActionComposeUtils from '@pages/inbox/report/ReportActionCompose/ReportActionComposeUtils'; +import useEditComposerToggle from '@pages/inbox/report/ReportActionCompose/useEditComposerToggle'; +import type {ReportActionEditMessageState} from '@pages/inbox/report/ReportActionEditMessageContext'; +import CONST from '@src/CONST'; + +jest.mock('@pages/inbox/report/ReportActionCompose/ComposerContext', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@pages/inbox/report/ReportActionCompose/ComposerContext'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + useComposerActions: jest.fn(), + useComposerEditActions: jest.fn(), + useComposerEditState: jest.fn(), + }; +}); + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); + +jest.mock('@pages/inbox/report/ReportActionCompose/ReportActionComposeUtils', () => ({ + __esModule: true, + default: { + updateNativeSelectionValue: jest.fn(), + }, +})); + +jest.mock('@libs/getPlatform', () => ({ + __esModule: true, + default: () => 'web', +})); + +const mockUseComposerEditState = jest.mocked(ComposerContext.useComposerEditState); +const mockUseComposerActions = jest.mocked(ComposerContext.useComposerActions); +const mockUseComposerEditActions = jest.mocked(ComposerContext.useComposerEditActions); +const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; +const mockUpdateNativeSelectionValue = jest.mocked(ReportActionComposeUtils.updateNativeSelectionValue); + +function makeComposerRef(overrides?: Partial): RefObject { + return { + current: { + blur: jest.fn(), + isFocused: jest.fn(() => false), + setNativeProps: jest.fn(), + setSelection: jest.fn(), + focus: jest.fn(), + ...overrides, + } as unknown as ComposerRef, + }; +} + +function defaultComposerEditState(overrides?: Partial): ComposerEditState { + return { + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF, + isEditingInComposer: false, + editingReportID: null, + editingReportActionID: null, + editingReportAction: null, + editingMessage: null, + currentEditMessageSelection: null, + draftComment: undefined, + effectiveDraft: undefined, + didResetComposerHeightWhileEditing: false, + ...overrides, + }; +} + +function defaultComposerActions(overrides?: Partial): ComposerActions { + return { + setText: jest.fn(), + setMenuVisibility: jest.fn(), + setIsFullComposerAvailable: jest.fn(), + setComposerRef: jest.fn(), + onBlur: jest.fn(), + onFocus: jest.fn(), + onAddActionPressed: jest.fn(), + onItemSelected: jest.fn(), + onTriggerAttachmentPicker: jest.fn(), + clearComposer: jest.fn(), + ...overrides, + }; +} + +type MockComposerEditActions = { + publishDraft: (draftMessage: string) => void; + deleteDraft: () => void; + setDidResetComposerHeightWhileEditing: (value: boolean) => void; +}; + +function defaultComposerEditActions(overrides?: Partial): MockComposerEditActions { + return { + publishDraft: jest.fn(), + deleteDraft: jest.fn(), + setDidResetComposerHeightWhileEditing: jest.fn(), + ...overrides, + }; +} + +function wideLayoutResult(): ResponsiveLayoutResult { + return { + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + onboardingIsMediumOrLargerScreenWidth: true, + isLargeScreenWidth: true, + isExtraLargeScreenWidth: false, + isSmallScreen: false, + isInLandscapeMode: false, + }; +} + +function narrowLayoutResult(): ResponsiveLayoutResult { + return { + shouldUseNarrowLayout: true, + isSmallScreenWidth: true, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + onboardingIsMediumOrLargerScreenWidth: false, + isLargeScreenWidth: false, + isExtraLargeScreenWidth: false, + isSmallScreen: true, + isInLandscapeMode: false, + }; +} + +describe('useEditComposerToggle', () => { + const composerEditStateRef = {current: defaultComposerEditState()}; + + beforeEach(() => { + jest.clearAllMocks(); + composerEditStateRef.current = defaultComposerEditState(); + mockUseComposerEditState.mockImplementation(() => composerEditStateRef.current); + mockUseComposerActions.mockReturnValue(defaultComposerActions()); + mockUseComposerEditActions.mockReturnValue(defaultComposerEditActions()); + mockUseResponsiveLayout.mockReturnValue(narrowLayoutResult()); + }); + + it('does not run apply logic while editingState is submitted', () => { + const onValueChange = jest.fn(); + const composerRef = makeComposerRef(); + composerEditStateRef.current = defaultComposerEditState({editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.SUBMITTED, editingMessage: 'hello'}); + + renderHook(() => + useEditComposerToggle({ + selection: {start: 0, end: 0}, + composerRef, + onValueChange, + }), + ); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('on narrow layout, when editing starts, applies editing message, selection at end, and focus', () => { + const onValueChange = jest.fn(); + const onSelectionChange = jest.fn(); + const onFocus = jest.fn(); + const composerRef = makeComposerRef(); + const priorSelection: TextSelection = {start: 0, end: 0}; + + const {rerender} = renderHook( + (props: {selection: TextSelection; value: string}) => + useEditComposerToggle({ + selection: props.selection, + composerRef, + onValueChange, + onSelectionChange, + onFocus, + }), + {initialProps: {selection: priorSelection, value: 'keep my draft'}}, + ); + + composerEditStateRef.current = defaultComposerEditState({ + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING, + editingMessage: 'edited body', + editingReportActionID: '100', + currentEditMessageSelection: {start: 1, end: 2}, + }); + rerender({selection: priorSelection, value: 'keep my draft'}); + + const expectedEnd = 'edited body'.length; + expect(onValueChange).toHaveBeenCalledWith('edited body'); + expect(onSelectionChange).toHaveBeenCalledWith({start: expectedEnd, end: expectedEnd}); + expect(onFocus).toHaveBeenCalled(); + }); + + it('on wide layout, when editing starts, leaves composer value unchanged (inline editor handles edit)', () => { + mockUseResponsiveLayout.mockReturnValue(wideLayoutResult()); + + const onValueChange = jest.fn(); + const composerRef = makeComposerRef(); + + const {rerender} = renderHook(() => + useEditComposerToggle({ + selection: {start: 0, end: 0}, + composerRef, + onValueChange, + }), + ); + + composerEditStateRef.current = defaultComposerEditState({ + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING, + editingMessage: 'from thread', + }); + rerender({}); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('on narrow, when edit ends, restores prior draft and selection', () => { + const onValueChange = jest.fn(); + const onSelectionChange = jest.fn(); + const composerRef = makeComposerRef(); + const priorSelection: TextSelection = {start: 2, end: 5}; + + // Start with edit off so wasEditingRef is false; then turn editing on to capture previousDraftSelectionRef. + const {rerender} = renderHook( + (props: {selection: TextSelection; draftComment: string; editing: boolean}) => { + composerEditStateRef.current = defaultComposerEditState( + props.editing + ? {editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING, editingMessage: 'e', editingReportActionID: '1', draftComment: props.draftComment} + : {editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF, draftComment: props.draftComment}, + ); + return useEditComposerToggle({ + selection: props.selection, + composerRef, + onValueChange, + onSelectionChange, + }); + }, + {initialProps: {selection: priorSelection, draftComment: 'restored', editing: false}}, + ); + + rerender({selection: priorSelection, draftComment: 'restored', editing: true}); + + onValueChange.mockClear(); + onSelectionChange.mockClear(); + + rerender({selection: priorSelection, draftComment: 'restored', editing: false}); + + expect(onValueChange).toHaveBeenCalledWith('restored'); + expect(onSelectionChange).toHaveBeenCalledWith(priorSelection); + expect(composerRef.current?.blur).not.toHaveBeenCalled(); + }); + + it('resets the manual composer height after a submitted narrow composer edit ends', () => { + const setDidResetComposerHeight = jest.fn(); + const composerRef = makeComposerRef(); + mockUseComposerEditActions.mockReturnValue(defaultComposerEditActions({setDidResetComposerHeightWhileEditing: setDidResetComposerHeight})); + + const {rerender} = renderHook( + (editingState: ComposerEditState['editingState']) => { + composerEditStateRef.current = defaultComposerEditState({ + editingState, + isEditingInComposer: editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING, + editingMessage: 'edited message', + editingReportActionID: '1', + }); + + return useEditComposerToggle({ + selection: {start: 0, end: 0}, + composerRef, + }); + }, + {initialProps: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF}, + ); + + rerender(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING); + rerender(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.SUBMITTED); + rerender(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF); + + expect(setDidResetComposerHeight).toHaveBeenCalledWith(false); + }); + + it('on narrow, when switching the message being edited, applies the new message', () => { + const onValueChange = jest.fn(); + const onFocus = jest.fn(); + const composerRef = makeComposerRef(); + + composerEditStateRef.current = defaultComposerEditState({ + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING, + editingMessage: 'first', + editingReportActionID: 'a', + }); + + const {rerender} = renderHook( + (id: string) => { + composerEditStateRef.current = defaultComposerEditState({ + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING, + editingMessage: id === 'a' ? 'first' : 'second', + editingReportActionID: id, + }); + return useEditComposerToggle({ + selection: {start: 0, end: 0}, + composerRef, + onValueChange, + onFocus, + }); + }, + {initialProps: 'a'}, + ); + + onValueChange.mockClear(); + onFocus.mockClear(); + + rerender('b'); + + expect(onValueChange).toHaveBeenCalledWith('second'); + expect(onFocus).toHaveBeenCalled(); + }); + + it('when layout goes from wide to narrow while editing, loads editing message into the composer', () => { + mockUseResponsiveLayout.mockReturnValue(wideLayoutResult()); + + const onValueChange = jest.fn(); + const onFocus = jest.fn(); + const composerRef = makeComposerRef(); + + composerEditStateRef.current = defaultComposerEditState({ + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING, + editingMessage: 'wide first', + }); + + const {rerender} = renderHook(() => + useEditComposerToggle({ + selection: {start: 0, end: 0}, + composerRef, + onValueChange, + onFocus, + }), + ); + + onValueChange.mockClear(); + + mockUseResponsiveLayout.mockReturnValue(narrowLayoutResult()); + rerender({}); + + expect(onValueChange).toHaveBeenCalledWith('wide first'); + expect(onFocus).toHaveBeenCalled(); + }); + + it('when layout goes from narrow to wide while editing, restores the normal draft in the composer', () => { + const onValueChange = jest.fn(); + const composerRef = makeComposerRef(); + + composerEditStateRef.current = defaultComposerEditState({ + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING, + editingMessage: 'editing in narrow', + draftComment: 'plain draft for wide', + }); + + const {rerender} = renderHook( + (narrow: boolean) => { + mockUseResponsiveLayout.mockReturnValue(narrow ? narrowLayoutResult() : wideLayoutResult()); + return useEditComposerToggle({ + selection: {start: 0, end: 0}, + composerRef, + onValueChange, + }); + }, + {initialProps: true}, + ); + + onValueChange.mockClear(); + + rerender(false); + + expect(onValueChange).toHaveBeenCalledWith('plain draft for wide'); + }); + + it('passes selection through to ReportActionComposeUtils when toggling (non-iOS / web mock)', () => { + const onValueChange = jest.fn(); + const onSelectionChange = jest.fn(); + const composerRef = makeComposerRef(); + + const {rerender} = renderHook( + (editing: boolean) => { + composerEditStateRef.current = defaultComposerEditState( + editing + ? {editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING, editingMessage: 'hi', editingReportActionID: '1'} + : {editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF}, + ); + return useEditComposerToggle({ + selection: {start: 0, end: 0}, + composerRef, + onValueChange, + onSelectionChange, + }); + }, + {initialProps: false}, + ); + + rerender(true); + + expect(mockUpdateNativeSelectionValue).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts new file mode 100644 index 000000000000..6d544b9b37df --- /dev/null +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -0,0 +1,136 @@ +import {act, renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import {editReportComment} from '@libs/actions/Report'; +import {showDeleteModal} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import useEditMessage from '@pages/inbox/report/ReportActionCompose/useEditMessage'; +import ONYXKEYS from '@src/ONYXKEYS'; +import * as LHNTestUtils from '../../utils/LHNTestUtils'; + +jest.mock('@libs/actions/Report', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + editReportComment: jest.fn(), + clearReportActionDrafts: jest.fn(), + }; +}); + +jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => ({ + useReportActionActiveEditActions: () => ({ + submitEdit: jest.fn(), + stopEditing: jest.fn(), + }), +})); + +jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@pages/inbox/report/ContextMenu/ReportActionContextMenu'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + showDeleteModal: jest.fn(), + isActiveReportAction: jest.fn(() => false), + }; +}); + +jest.mock('@hooks/useAncestors', () => ({ + __esModule: true, + default: () => [], +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({email: 'user@test.com'}), +})); + +jest.mock('@hooks/useReportIsArchived', () => ({ + __esModule: true, + default: () => false, +})); + +jest.mock('@hooks/useReportScrollManager', () => ({ + __esModule: true, + default: () => ({scrollToIndex: jest.fn()}), +})); + +jest.mock('@libs/ReportUtils', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/ReportUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getOriginalReportID: () => undefined, + }; +}); + +const mockEditReportComment = jest.mocked(editReportComment); +const mockShowDeleteModal = jest.mocked(showDeleteModal); + +type HookProps = Parameters[0]; + +type DebouncedValidator = HookProps['debouncedCommentMaxLengthValidation']; + +function makeDebouncedValidator({flushResult}: {flushResult: boolean}): DebouncedValidator { + return { + flush: jest.fn(() => flushResult), + cancel: jest.fn(), + } as unknown as DebouncedValidator; +} + +describe('useEditMessage', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + function renderUseEditMessage(overrides?: Partial) { + const report = LHNTestUtils.getFakeReport(); + const reportAction = LHNTestUtils.getFakeReportAction(); + + const props: HookProps = { + reportID: report.reportID, + originalReportID: report.reportID, + reportAction, + debouncedCommentMaxLengthValidation: makeDebouncedValidator({flushResult: true}), + composerRef: {current: {blur: jest.fn()} as never}, + ...overrides, + }; + const hook = renderHook(() => useEditMessage(props)); + return {hook, props}; + } + + it('should not publish when validation flush fails', async () => { + const {hook} = renderUseEditMessage({ + debouncedCommentMaxLengthValidation: makeDebouncedValidator({flushResult: false}), + }); + + act(() => { + hook.result.current.publishDraft('Hello'); + }); + + expect(mockEditReportComment).toHaveBeenCalledTimes(0); + expect(mockShowDeleteModal).toHaveBeenCalledTimes(0); + }); + + it('should open delete modal when publishing an empty (trimmed) message', async () => { + const {hook, props} = renderUseEditMessage(); + + act(() => { + hook.result.current.publishDraft(' '); + }); + + expect(mockEditReportComment).toHaveBeenCalledTimes(0); + expect(mockShowDeleteModal).toHaveBeenCalledTimes(1); + + const args = mockShowDeleteModal.mock.calls.at(0); + expect(args?.[1]?.reportActionID).toBe(props.reportAction?.reportActionID); + }); +}); diff --git a/tests/unit/hooks/useTransactionThreadReportID.test.ts b/tests/unit/hooks/useTransactionThreadReportID.test.ts new file mode 100644 index 000000000000..c69073cd9306 --- /dev/null +++ b/tests/unit/hooks/useTransactionThreadReportID.test.ts @@ -0,0 +1,281 @@ +import {renderHook} from '@testing-library/react-native'; +import type {OnyxKey, ResultMetadata, UseOnyxResult} from 'react-native-onyx'; +import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import useTransactionThreadReportID from '@hooks/useTransactionThreadReportID'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, Transaction} from '@src/types/onyx'; + +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => ({isOffline: false}), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@hooks/usePaginatedReportActions', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@hooks/useReportTransactionsCollection', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockUseOnyx = jest.mocked(useOnyx); +const mockUsePaginatedReportActions = jest.mocked(usePaginatedReportActions); +const mockUseReportTransactionsCollection = jest.mocked(useReportTransactionsCollection); + +const MONEY_REPORT_ID = 'money-report-test-1'; +const CHAT_REPORT_ID = 'chat-report-test-1'; +const THREAD_REPORT_ID = 'thread-report-test-1'; + +function collectionReportKey(reportId: string | undefined): string { + return `${ONYXKEYS.COLLECTION.REPORT}${reportId}`; +} + +function makeExpenseReportWithChat(overrides: Partial = {}): Report { + return { + reportID: MONEY_REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: CHAT_REPORT_ID, + ...overrides, + } as Report; +} + +function makeDMChatReport(): Report { + return { + reportID: CHAT_REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + } as Report; +} + +function makeIOUCreatedAction(extra: Partial = {}): ReportAction { + return { + reportActionID: 'iou-created-1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: 1, + created: '2024-01-01 10:00:00.000', + // Non-empty html so legacy deleted-comment detection in isDeletedAction does not treat this as deleted. + message: [{type: 'TEXT', html: 'n', text: 'n', isEdited: false, isDeletedParentAction: false}], + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: 'txn-1', + }, + ...extra, + } as ReportAction; +} + +function makeSentMoneyPayAction(extra: Partial = {}): ReportAction { + return { + reportActionID: 'iou-pay-1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: 2, + created: '2024-01-01 09:00:00.000', + message: [{type: 'TEXT', html: 's', text: 's', isEdited: false, isDeletedParentAction: false}], + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.PAY, + IOUDetails: {amount: 50, currency: 'USD'}, + }, + childReportID: THREAD_REPORT_ID, + ...extra, + } as ReportAction; +} + +const loadedReportMetadata: ResultMetadata = {status: 'loaded'}; + +function asReportOnyxResult(report: Report | undefined): UseOnyxResult { + return [report, loadedReportMetadata]; +} + +function wireReportOnyx(moneyReport: Report | undefined, chatReport: Report | undefined, hookReportID: string | undefined): void { + mockUseOnyx.mockImplementation((key: OnyxKey) => { + const moneyReportKey = collectionReportKey(hookReportID); + if (key === moneyReportKey) { + return asReportOnyxResult(moneyReport); + } + const linkedChatReportID = moneyReport?.chatReportID; + if (linkedChatReportID !== undefined && linkedChatReportID !== '' && key === collectionReportKey(linkedChatReportID)) { + return asReportOnyxResult(chatReport); + } + + return asReportOnyxResult(undefined); + }); +} + +/** + * Mirrors `DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS[*].transactions` — keyed by transaction id string. + */ +function transactionsRecordForReport(transactionsInput: Transaction[]): Record { + return Object.fromEntries(transactionsInput.filter(Boolean).map((t) => [t.transactionID, t])); +} + +describe('useTransactionThreadReportID', () => { + beforeEach(() => { + mockUseOnyx.mockReset(); + mockUsePaginatedReportActions.mockReset(); + mockUseReportTransactionsCollection.mockReset(); + + mockUsePaginatedReportActions.mockReturnValue({ + reportActions: [], + linkedAction: undefined, + oldestUnreadReportAction: undefined, + sortedAllReportActions: undefined, + hasOlderActions: false, + hasNewerActions: false, + report: undefined, + }); + mockUseReportTransactionsCollection.mockReturnValue({}); + }); + + it('returns empty derived values when reportID is undefined', () => { + wireReportOnyx(undefined, undefined, undefined); + + const {result} = renderHook(() => useTransactionThreadReportID(undefined)); + + expect(result.current.transactionThreadReportID).toBeUndefined(); + expect(result.current.effectiveTransactionThreadReportID).toBeUndefined(); + expect(result.current.reportActions).toEqual([]); + }); + + it('returns undefined thread ids when the report is not an IOU, expense, or invoice report', () => { + const chatLikeReport = {...makeExpenseReportWithChat(), type: CONST.REPORT.TYPE.CHAT} as Report; + + wireReportOnyx(chatLikeReport, makeDMChatReport(), MONEY_REPORT_ID); + const iouCreate = makeIOUCreatedAction({childReportID: THREAD_REPORT_ID}); + mockUsePaginatedReportActions.mockReturnValue({ + reportActions: [iouCreate], + linkedAction: undefined, + oldestUnreadReportAction: undefined, + sortedAllReportActions: [iouCreate], + hasOlderActions: false, + hasNewerActions: false, + report: chatLikeReport, + }); + mockUseReportTransactionsCollection.mockReturnValue( + transactionsRecordForReport([ + { + transactionID: 'txn-1', + reportID: MONEY_REPORT_ID, + } as Transaction, + ]), + ); + + const {result} = renderHook(() => useTransactionThreadReportID(MONEY_REPORT_ID)); + + expect(result.current.transactionThreadReportID).toBeUndefined(); + expect(result.current.effectiveTransactionThreadReportID).toBeUndefined(); + }); + + it('returns childReportID when exactly one qualifying IOU CREATE exists and visible transactions resolve to one id', () => { + const moneyReport = makeExpenseReportWithChat(); + + wireReportOnyx(moneyReport, makeDMChatReport(), MONEY_REPORT_ID); + const iouCreate = makeIOUCreatedAction({childReportID: THREAD_REPORT_ID}); + mockUsePaginatedReportActions.mockReturnValue({ + reportActions: [iouCreate], + linkedAction: undefined, + oldestUnreadReportAction: undefined, + sortedAllReportActions: [iouCreate], + hasOlderActions: false, + hasNewerActions: false, + report: moneyReport, + }); + mockUseReportTransactionsCollection.mockReturnValue( + transactionsRecordForReport([ + { + transactionID: 'txn-1', + reportID: MONEY_REPORT_ID, + } as Transaction, + ]), + ); + + const {result} = renderHook(() => useTransactionThreadReportID(MONEY_REPORT_ID)); + + expect(result.current.transactionThreadReportID).toBe(THREAD_REPORT_ID); + expect(result.current.effectiveTransactionThreadReportID).toBe(THREAD_REPORT_ID); + expect(result.current.reportActions?.map((a) => a.reportActionID)).toEqual(['iou-created-1']); + }); + + it('uses CONST.FAKE_REPORT_ID when no childReportID is set on the lone IOU request action', () => { + const moneyReport = makeExpenseReportWithChat(); + + wireReportOnyx(moneyReport, makeDMChatReport(), MONEY_REPORT_ID); + const iouCreate = makeIOUCreatedAction({}); + mockUsePaginatedReportActions.mockReturnValue({ + reportActions: [iouCreate], + linkedAction: undefined, + oldestUnreadReportAction: undefined, + sortedAllReportActions: [iouCreate], + hasOlderActions: false, + hasNewerActions: false, + report: moneyReport, + }); + mockUseReportTransactionsCollection.mockReturnValue( + transactionsRecordForReport([ + { + transactionID: 'txn-1', + reportID: MONEY_REPORT_ID, + } as Transaction, + ]), + ); + + const {result} = renderHook(() => useTransactionThreadReportID(MONEY_REPORT_ID)); + + expect(result.current.transactionThreadReportID).toBe(CONST.FAKE_REPORT_ID); + expect(result.current.effectiveTransactionThreadReportID).toBe(CONST.FAKE_REPORT_ID); + }); + + it('returns undefined when more than one visible transaction is associated with the report', () => { + const moneyReport = makeExpenseReportWithChat(); + + wireReportOnyx(moneyReport, makeDMChatReport(), MONEY_REPORT_ID); + const iouCreate = makeIOUCreatedAction({childReportID: THREAD_REPORT_ID}); + mockUsePaginatedReportActions.mockReturnValue({ + reportActions: [iouCreate], + linkedAction: undefined, + oldestUnreadReportAction: undefined, + sortedAllReportActions: [iouCreate], + hasOlderActions: false, + hasNewerActions: false, + report: moneyReport, + }); + mockUseReportTransactionsCollection.mockReturnValue( + transactionsRecordForReport([{transactionID: 'txn-1', reportID: MONEY_REPORT_ID} as Transaction, {transactionID: 'txn-2', reportID: MONEY_REPORT_ID} as Transaction]), + ); + + const {result} = renderHook(() => useTransactionThreadReportID(MONEY_REPORT_ID)); + + expect(result.current.transactionThreadReportID).toBeUndefined(); + expect(result.current.effectiveTransactionThreadReportID).toBeUndefined(); + }); + + it('forces effectiveTransactionThreadReportID to undefined while keeping the derived id when a sent-money IOU PAY is present', () => { + const moneyReport = makeExpenseReportWithChat({type: CONST.REPORT.TYPE.IOU}); + const chatReport = makeDMChatReport(); + const payAction = makeSentMoneyPayAction(); + + wireReportOnyx(moneyReport, chatReport, MONEY_REPORT_ID); + mockUsePaginatedReportActions.mockReturnValue({ + reportActions: [payAction], + linkedAction: undefined, + oldestUnreadReportAction: undefined, + sortedAllReportActions: [payAction], + hasOlderActions: false, + hasNewerActions: false, + report: moneyReport, + }); + mockUseReportTransactionsCollection.mockReturnValue({}); + + const {result} = renderHook(() => useTransactionThreadReportID(MONEY_REPORT_ID)); + + expect(result.current.transactionThreadReportID).toBe(THREAD_REPORT_ID); + expect(result.current.effectiveTransactionThreadReportID).toBeUndefined(); + }); +}); diff --git a/tests/unit/pages/inbox/report/ReportActionEditMessageContext.test.tsx b/tests/unit/pages/inbox/report/ReportActionEditMessageContext.test.tsx new file mode 100644 index 000000000000..e528b0b62ff1 --- /dev/null +++ b/tests/unit/pages/inbox/report/ReportActionEditMessageContext.test.tsx @@ -0,0 +1,581 @@ +import {act, renderHook} from '@testing-library/react-native'; +import React from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; +import useAncestors from '@hooks/useAncestors'; +import * as ReportUtils from '@libs/ReportUtils'; +import {ReportActionEditMessageContextProvider, useReportActionActiveEdit, useReportActionActiveEditActions} from '@pages/inbox/report/ReportActionEditMessageContext'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, ReportActions, ReportActionsDrafts} from '@src/types/onyx'; +import {getFakeReport, getFakeReportAction} from '../../../../utils/LHNTestUtils'; + +jest.mock('@hooks/useAncestors', () => ({ + __esModule: true, + default: jest.fn(() => []), +})); + +const mockUseOnyx = jest.fn(); +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: (key: unknown, opts?: {selector?: (collection: unknown) => unknown}) => mockUseOnyx(key, opts) as unknown[], +})); + +const mockUseAncestors = jest.mocked(useAncestors); + +let getOriginalReportIDSpy: jest.SpiedFunction; + +const MAIN_REPORT_ID = '50001'; +const THREAD_REPORT_ID = '50002'; +const THREAD_ACTION_ID = '9001'; +const MAIN_ACTION_ID = '9002'; +const ANCESTOR_REPORT_ID = '60001'; +const ANCESTOR_ACTION_ID = '91003'; + +type OnyxSelectorOptions = {selector?: (collection: unknown) => unknown}; + +type BuildOnyxLayerParams = { + mainReport: Report; + mainReportActions: ReportActions; + fullReportActionsCollection: OnyxCollection; + fullDraftsCollection: OnyxCollection; +}; + +function buildUseOnyxImplementation(params: BuildOnyxLayerParams) { + return (key: unknown, opts?: OnyxSelectorOptions) => { + const keyStr = key as string; + + if (keyStr === `${ONYXKEYS.COLLECTION.REPORT}${MAIN_REPORT_ID}`) { + return [params.mainReport]; + } + + if (keyStr === `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`) { + return [params.mainReportActions]; + } + + if (keyStr === ONYXKEYS.COLLECTION.REPORT_ACTIONS && opts?.selector) { + return [opts.selector(params.fullReportActionsCollection)]; + } + + if (keyStr === ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS && opts?.selector) { + return [opts.selector(params.fullDraftsCollection)]; + } + + return [undefined]; + }; +} + +function createReportWithId(reportID: string): Report { + const base = getFakeReport(); + return { + ...base, + reportID, + }; +} + +function createReportActionWithId(reportActionID: string): ReportAction { + const base = getFakeReportAction(); + return { + ...base, + reportActionID, + }; +} + +function renderActiveEditHook(mockImplementation: ReturnType, effectiveTransactionThreadReportID?: string) { + mockUseOnyx.mockImplementation(mockImplementation); + + function EditProviderWrapper({children}: {children: React.ReactNode}) { + return ( + + {children} + + ); + } + + return renderHook(() => useReportActionActiveEdit(), { + wrapper: EditProviderWrapper, + }); +} + +function renderActiveEditAndActionsHook(mockImplementation: ReturnType, effectiveTransactionThreadReportID?: string) { + mockUseOnyx.mockImplementation(mockImplementation); + + function EditProviderWrapper({children}: {children: React.ReactNode}) { + return ( + + {children} + + ); + } + + return renderHook( + () => ({ + activeEdit: useReportActionActiveEdit(), + activeEditActions: useReportActionActiveEditActions(), + }), + { + wrapper: EditProviderWrapper, + }, + ); +} + +function resetProviderTestState() { + jest.clearAllMocks(); + mockUseAncestors.mockReturnValue([]); + getOriginalReportIDSpy.mockImplementation(() => undefined); + mockUseOnyx.mockReturnValue([undefined]); +} + +describe('ReportActionEditMessageContextProvider', () => { + beforeAll(() => { + // Spy the real export so the provider's import uses the same function (jest.mock replacement did not intercept calls). + getOriginalReportIDSpy = jest.spyOn(ReportUtils, 'getOriginalReportID'); + }); + + afterAll(() => { + getOriginalReportIDSpy.mockRestore(); + }); + + beforeEach(() => { + resetProviderTestState(); + }); + + describe('transaction thread report', () => { + it('surfaces an edit on the effective transaction thread when it differs from the visible report', () => { + const threadReportAction = createReportActionWithId(THREAD_ACTION_ID); + + const mainReport = createReportWithId(MAIN_REPORT_ID); + const mainReportActions: ReportActions = {}; + + const fullReportActionsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`]: mainReportActions, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${THREAD_REPORT_ID}`]: { + [THREAD_ACTION_ID]: threadReportAction, + }, + }; + + const fullDraftsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${MAIN_REPORT_ID}`]: {}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${THREAD_REPORT_ID}`]: { + [THREAD_ACTION_ID]: { + message: 'edited on thread', + }, + }, + }; + + const mockImpl = buildUseOnyxImplementation({ + mainReport, + mainReportActions, + fullReportActionsCollection, + fullDraftsCollection, + }); + + const {result} = renderActiveEditHook(mockImpl, THREAD_REPORT_ID); + + expect(result.current.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING); + expect(result.current.editingReportID).toBe(THREAD_REPORT_ID); + expect(result.current.editingReportActionID).toBe(THREAD_ACTION_ID); + expect(result.current.editingReportAction).toEqual(threadReportAction); + expect(result.current.editingMessage).toBe('edited on thread'); + }); + + it('does not load transaction-thread drafts when effective thread ID is undefined', () => { + const threadReportAction = createReportActionWithId(THREAD_ACTION_ID); + const mainReport = createReportWithId(MAIN_REPORT_ID); + + const fullReportActionsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`]: {}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${THREAD_REPORT_ID}`]: { + [THREAD_ACTION_ID]: threadReportAction, + }, + }; + + const fullDraftsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${MAIN_REPORT_ID}`]: {}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${THREAD_REPORT_ID}`]: { + [THREAD_ACTION_ID]: { + message: 'should be ignored', + }, + }, + }; + + const mockImpl = buildUseOnyxImplementation({ + mainReport, + mainReportActions: {}, + fullReportActionsCollection, + fullDraftsCollection, + }); + + const {result} = renderActiveEditHook(mockImpl); + + expect(result.current.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF); + expect(result.current.editingReportID).toBeNull(); + }); + + it('does not load transaction-thread drafts when effective thread ID is the fake report ID', () => { + const threadReportAction = createReportActionWithId(THREAD_ACTION_ID); + const mainReport = createReportWithId(MAIN_REPORT_ID); + + const fullReportActionsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`]: {}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${THREAD_REPORT_ID}`]: { + [THREAD_ACTION_ID]: threadReportAction, + }, + }; + + const fullDraftsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${MAIN_REPORT_ID}`]: {}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${THREAD_REPORT_ID}`]: { + [THREAD_ACTION_ID]: { + message: 'should be ignored', + }, + }, + }; + + const mockImpl = buildUseOnyxImplementation({ + mainReport, + mainReportActions: {}, + fullReportActionsCollection, + fullDraftsCollection, + }); + + const {result} = renderActiveEditHook(mockImpl, CONST.FAKE_REPORT_ID); + + expect(result.current.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF); + expect(result.current.editingReportID).toBeNull(); + }); + + it('does not load transaction-thread drafts when effective thread ID equals the visible report ID', () => { + const threadReportAction = createReportActionWithId(THREAD_ACTION_ID); + const mainReport = createReportWithId(MAIN_REPORT_ID); + + const fullReportActionsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`]: {}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${THREAD_REPORT_ID}`]: { + [THREAD_ACTION_ID]: threadReportAction, + }, + }; + + const fullDraftsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${MAIN_REPORT_ID}`]: {}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${THREAD_REPORT_ID}`]: { + [THREAD_ACTION_ID]: { + message: 'draft only on separate thread key', + }, + }, + }; + + const mockImpl = buildUseOnyxImplementation({ + mainReport, + mainReportActions: {}, + fullReportActionsCollection, + fullDraftsCollection, + }); + + const {result} = renderActiveEditHook(mockImpl, MAIN_REPORT_ID); + + expect(result.current.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF); + expect(result.current.editingReportID).toBeNull(); + }); + + it('does not surface additional edits when a draft exists without its report action', () => { + const mainReport = createReportWithId(MAIN_REPORT_ID); + + const fullReportActionsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`]: {}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${THREAD_REPORT_ID}`]: {}, + }; + + const fullDraftsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${MAIN_REPORT_ID}`]: {}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${THREAD_REPORT_ID}`]: { + [THREAD_ACTION_ID]: { + message: 'orphan draft', + }, + }, + }; + + const mockImpl = buildUseOnyxImplementation({ + mainReport, + mainReportActions: {}, + fullReportActionsCollection, + fullDraftsCollection, + }); + + const {result} = renderActiveEditHook(mockImpl, THREAD_REPORT_ID); + + expect(result.current.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF); + expect(result.current.editingReportID).toBeNull(); + }); + + it('prefers the main report draft over a transaction-thread draft when both exist', () => { + const mainReportAction = createReportActionWithId(MAIN_ACTION_ID); + const threadReportAction = createReportActionWithId(THREAD_ACTION_ID); + + const mainReport = createReportWithId(MAIN_REPORT_ID); + const mainReportActions: ReportActions = { + [MAIN_ACTION_ID]: mainReportAction, + }; + + const fullReportActionsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`]: mainReportActions, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${THREAD_REPORT_ID}`]: { + [THREAD_ACTION_ID]: threadReportAction, + }, + }; + + const fullDraftsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${MAIN_REPORT_ID}`]: { + [MAIN_ACTION_ID]: { + message: 'main wins', + }, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${THREAD_REPORT_ID}`]: { + [THREAD_ACTION_ID]: { + message: 'thread loses', + }, + }, + }; + + const mockImpl = buildUseOnyxImplementation({ + mainReport, + mainReportActions, + fullReportActionsCollection, + fullDraftsCollection, + }); + + const {result} = renderActiveEditHook(mockImpl, THREAD_REPORT_ID); + + expect(result.current.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING); + expect(result.current.editingReportID).toBe(MAIN_REPORT_ID); + expect(result.current.editingReportActionID).toBe(MAIN_ACTION_ID); + expect(result.current.editingMessage).toBe('main wins'); + }); + }); + + describe('ancestor drafts', () => { + it('prefers an ancestor draft over the main report draft', () => { + const ancestorReport = createReportWithId(ANCESTOR_REPORT_ID); + const ancestorAction = createReportActionWithId(ANCESTOR_ACTION_ID); + const mainReportAction = createReportActionWithId(MAIN_ACTION_ID); + const mainReport = createReportWithId(MAIN_REPORT_ID); + + mockUseAncestors.mockReturnValue([{report: ancestorReport, reportAction: ancestorAction, shouldDisplayNewMarker: false}]); + getOriginalReportIDSpy.mockImplementation(() => ANCESTOR_REPORT_ID); + + const fullReportActionsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`]: { + [MAIN_ACTION_ID]: mainReportAction, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${ANCESTOR_REPORT_ID}`]: { + [ANCESTOR_ACTION_ID]: ancestorAction, + }, + }; + + const fullDraftsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${MAIN_REPORT_ID}`]: { + [MAIN_ACTION_ID]: { + message: 'main draft message', + }, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${ANCESTOR_REPORT_ID}`]: { + [ANCESTOR_ACTION_ID]: { + message: 'ancestor draft message', + }, + }, + }; + + const mockImpl = buildUseOnyxImplementation({ + mainReport, + mainReportActions: { + [MAIN_ACTION_ID]: mainReportAction, + }, + fullReportActionsCollection, + fullDraftsCollection, + }); + + const {result} = renderActiveEditHook(mockImpl); + + expect(result.current.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING); + expect(result.current.editingReportID).toBe(ANCESTOR_REPORT_ID); + expect(result.current.editingReportActionID).toBe(ANCESTOR_ACTION_ID); + expect(result.current.editingReportAction).toEqual(ancestorAction); + expect(result.current.editingMessage).toBe('ancestor draft message'); + }); + }); + + describe('main report draft only', () => { + it('surfaces a draft on the visible report when there are no ancestors', () => { + const mainReportAction = createReportActionWithId(MAIN_ACTION_ID); + const mainReport = createReportWithId(MAIN_REPORT_ID); + const mainReportActions: ReportActions = { + [MAIN_ACTION_ID]: mainReportAction, + }; + + const fullReportActionsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`]: mainReportActions, + }; + + const fullDraftsCollection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${MAIN_REPORT_ID}`]: { + [MAIN_ACTION_ID]: { + message: 'local edit', + }, + }, + }; + + const mockImpl = buildUseOnyxImplementation({ + mainReport, + mainReportActions, + fullReportActionsCollection, + fullDraftsCollection, + }); + + const {result} = renderActiveEditHook(mockImpl); + + expect(result.current.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING); + expect(result.current.editingReportID).toBe(MAIN_REPORT_ID); + expect(result.current.editingReportActionID).toBe(MAIN_ACTION_ID); + expect(result.current.editingReportAction).toEqual(mainReportAction); + expect(result.current.editingMessage).toBe('local edit'); + expect(result.current.currentEditMessageSelection).toEqual({ + start: 'local edit'.length, + end: 'local edit'.length, + }); + }); + + it('stays off when there are no drafts', () => { + const mainReport = createReportWithId(MAIN_REPORT_ID); + + const mockImpl = buildUseOnyxImplementation({ + mainReport, + mainReportActions: {}, + fullReportActionsCollection: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`]: {}, + }, + fullDraftsCollection: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${MAIN_REPORT_ID}`]: {}, + }, + }); + + const {result} = renderActiveEditHook(mockImpl); + + expect(result.current.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF); + expect(result.current.editingReportID).toBeNull(); + expect(result.current.editingMessage).toBeNull(); + expect(result.current.currentEditMessageSelection).toBeNull(); + }); + }); + + describe('actions', () => { + function localDraftMock() { + const mainReportAction = createReportActionWithId(MAIN_ACTION_ID); + const mainReport = createReportWithId(MAIN_REPORT_ID); + const mainReportActions: ReportActions = { + [MAIN_ACTION_ID]: mainReportAction, + }; + + return buildUseOnyxImplementation({ + mainReport, + mainReportActions, + fullReportActionsCollection: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`]: mainReportActions, + }, + fullDraftsCollection: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${MAIN_REPORT_ID}`]: { + [MAIN_ACTION_ID]: { + message: 'draft body', + }, + }, + }, + }); + } + + it('sets editing state to submitted when submitEdit runs', () => { + const {result} = renderActiveEditAndActionsHook(localDraftMock()); + + expect(result.current.activeEdit.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING); + + act(() => { + result.current.activeEditActions.submitEdit(); + }); + + expect(result.current.activeEdit.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.SUBMITTED); + }); + + it('clears transient state when stopEditing runs and no draft re-applies edit mode', () => { + const mainReport = createReportWithId(MAIN_REPORT_ID); + const emptyDraftsImpl = buildUseOnyxImplementation({ + mainReport, + mainReportActions: {}, + fullReportActionsCollection: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${MAIN_REPORT_ID}`]: {}, + }, + fullDraftsCollection: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${MAIN_REPORT_ID}`]: {}, + }, + }); + + const {result} = renderActiveEditAndActionsHook(emptyDraftsImpl); + + expect(result.current.activeEdit.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF); + + act(() => { + result.current.activeEditActions.setEditingMessage('leftover'); + }); + + act(() => { + result.current.activeEditActions.stopEditing(); + }); + + expect(result.current.activeEdit.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF); + expect(result.current.activeEdit.editingReportID).toBeNull(); + expect(result.current.activeEdit.editingMessage).toBeNull(); + expect(result.current.activeEdit.currentEditMessageSelection).toBeNull(); + }); + + it('updates currentEditMessageSelection while editing', () => { + const {result} = renderActiveEditAndActionsHook(localDraftMock()); + + const nextSelection = {start: 0, end: 4}; + + act(() => { + result.current.activeEditActions.setCurrentEditMessageSelection(nextSelection); + }); + + expect(result.current.activeEdit.currentEditMessageSelection).toEqual(nextSelection); + }); + + it('ignores setCurrentEditMessageSelection when not in editing state', () => { + const {result} = renderActiveEditAndActionsHook(localDraftMock()); + + act(() => { + result.current.activeEditActions.submitEdit(); + }); + + const selectionAfterSubmit = result.current.activeEdit.currentEditMessageSelection; + + act(() => { + result.current.activeEditActions.setCurrentEditMessageSelection({start: 0, end: 1}); + }); + + expect(result.current.activeEdit.editingState).toBe(CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.SUBMITTED); + expect(result.current.activeEdit.currentEditMessageSelection).toEqual(selectionAfterSubmit); + }); + + it('updates editing message via setEditingMessage', () => { + const {result} = renderActiveEditAndActionsHook(localDraftMock()); + + act(() => { + result.current.activeEditActions.setEditingMessage('replaced'); + }); + + expect(result.current.activeEdit.editingMessage).toBe('replaced'); + }); + }); +});