diff --git a/cspell.json b/cspell.json index ed2cbd7589da9..a1381504cb01e 100644 --- a/cspell.json +++ b/cspell.json @@ -902,7 +902,9 @@ "Synovus", "Wallester", "Wintrust", - "Zürcher" + "Zürcher", + "CARDFROZEN", + "CARDUNFROZEN" ], "ignorePaths": [ "src/languages/de.ts", diff --git a/package-lock.json b/package-lock.json index d7f56515a63fd..f237cb90bb814 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,7 +116,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.29.4", "react-native-nitro-sqlite": "9.2.0", - "react-native-onyx": "3.0.54", + "react-native-onyx": "3.0.57", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -34732,9 +34732,9 @@ } }, "node_modules/react-native-onyx": { - "version": "3.0.54", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.54.tgz", - "integrity": "sha512-202+t6reV9iQZnr5UOGHaJLCyO5X7Or0V2GHfvb5z10ZM1wnnZ0IKPkfUi+7WPZy4pFhEvDKymuaCIOu6++/rA==", + "version": "3.0.57", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.57.tgz", + "integrity": "sha512-7zHnwOdJ78pBmy1/ofJyN6NiBW/5Mo5vvt9oGgvXx2VmU02ZJY8Q2MvxpJq54Dad3wd70huud51drSEwSx/KNg==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -34755,8 +34755,7 @@ "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", "react-native-nitro-modules": ">=0.27.2", - "react-native-nitro-sqlite": "^9.2.0", - "react-native-performance": ">=5.1.0" + "react-native-nitro-sqlite": "^9.2.0" }, "peerDependenciesMeta": { "idb-keyval": { @@ -34770,9 +34769,6 @@ }, "react-native-nitro-sqlite": { "optional": true - }, - "react-native-performance": { - "optional": true } } }, diff --git a/package.json b/package.json index 8b85112d80700..84c87c6dd1641 100644 --- a/package.json +++ b/package.json @@ -179,7 +179,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.29.4", "react-native-nitro-sqlite": "9.2.0", - "react-native-onyx": "3.0.54", + "react-native-onyx": "3.0.57", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7cd99d9a7d165..27d3030213bb1 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1394,6 +1394,8 @@ const CONST = { CARD_REPLACED_VIRTUAL: 'CARDREPLACEDVIRTUAL', CARD_REPLACED: 'CARDREPLACED', CARD_ASSIGNED: 'CARDASSIGNED', + CARD_FROZEN: 'CARDFROZEN', + CARD_UNFROZEN: 'CARDUNFROZEN', PERSONAL_CARD_CONNECTION_BROKEN: 'PERSONALCARDCONNECTIONBROKEN', CHANGE_FIELD: 'CHANGEFIELD', // OldDot Action CHANGE_POLICY: 'CHANGEPOLICY', diff --git a/src/components/EmojiPicker/EmojiPicker.tsx b/src/components/EmojiPicker/EmojiPicker.tsx index 1fec2354705b4..659f5805c7017 100644 --- a/src/components/EmojiPicker/EmojiPicker.tsx +++ b/src/components/EmojiPicker/EmojiPicker.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {Activity, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import type {ForwardedRef, RefObject} from 'react'; import {Dimensions, View} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; @@ -236,45 +236,47 @@ function EmojiPicker({viewportOffsetTop, ref}: EmojiPickerProps) { }, [isEmojiPickerVisible, shouldUseNarrowLayout, emojiPopoverAnchorOrigin, getEmojiPopoverAnchor, hideEmojiPicker]); return ( - } - withoutOverlay={isWithoutOverlay} - popoverDimensions={{ - width: CONST.EMOJI_PICKER_SIZE.WIDTH, - height: CONST.EMOJI_PICKER_SIZE.HEIGHT, - }} - anchorAlignment={emojiPopoverAnchorOrigin} - outerStyle={StyleUtils.getOuterModalStyle(windowHeight, viewportOffsetTop)} - innerContainerStyle={styles.popoverInnerContainer} - anchorDimensions={emojiAnchorDimension.current} - avoidKeyboard - shouldSwitchPositionIfOverflow - shouldEnableNewFocusManagement - restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} - shouldSkipRemeasurement - > - - - { - emojiSearchInput.current = el; - }} - /> - - - + + } + withoutOverlay={isWithoutOverlay} + popoverDimensions={{ + width: CONST.EMOJI_PICKER_SIZE.WIDTH, + height: CONST.EMOJI_PICKER_SIZE.HEIGHT, + }} + anchorAlignment={emojiPopoverAnchorOrigin} + outerStyle={StyleUtils.getOuterModalStyle(windowHeight, viewportOffsetTop)} + innerContainerStyle={styles.popoverInnerContainer} + anchorDimensions={emojiAnchorDimension.current} + avoidKeyboard + shouldSwitchPositionIfOverflow + shouldEnableNewFocusManagement + restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} + shouldSkipRemeasurement + > + + + { + emojiSearchInput.current = el; + }} + /> + + + + ); } diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index d5d7be2247a2e..1dd324c9f13f0 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -6,6 +6,7 @@ import AnimatedSettlementButton from '@components/SettlementButton/AnimatedSettl import type {PaymentActionParams} from '@components/SettlementButton/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; +import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; import useOnyx from '@hooks/useOnyx'; import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; import usePolicy from '@hooks/usePolicy'; @@ -14,7 +15,13 @@ import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViol import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; -import {hasHeldExpenses as hasHeldExpensesReportUtils, hasUpdatedTotal, isAllowedToApproveExpenseReport, isInvoiceReport as isInvoiceReportUtil} from '@libs/ReportUtils'; +import { + hasHeldExpenses as hasHeldExpensesReportUtils, + hasOnlyNonReimbursableTransactions, + hasUpdatedTotal, + isAllowedToApproveExpenseReport, + isInvoiceReport as isInvoiceReportUtil, +} from '@libs/ReportUtils'; import {isExpensifyCardTransaction, isPending} from '@libs/TransactionUtils'; import {canApproveIOU, canIOUBePaid as canIOUBePaidAction, payInvoice, payMoneyRequest} from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -76,9 +83,12 @@ function PayPrimaryAction({ const hasOnlyPendingTransactions = transactions.length > 0 && transactions.every((t) => isExpensifyCardTransaction(t) && isPending(t)); const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, transaction ? [transaction] : undefined, false, undefined, invoiceReceiverPolicy); - const onlyShowPayElsewhere = - !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, transaction ? [transaction] : undefined, true, undefined, invoiceReceiverPolicy); - const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions); + const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(moneyRequestReport, transactions); + const onlyShowPayElsewhere = reportHasOnlyNonReimbursableTransactions + ? false + : !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, transaction ? [transaction] : undefined, true, undefined, invoiceReceiverPolicy); + const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere || reportHasOnlyNonReimbursableTransactions; const shouldShowApproveButton = (canApproveIOU(moneyRequestReport, policy, reportMetadata, transactions) && !hasOnlyPendingTransactions) || isApprovedAnimationRunning; const shouldDisableApproveButton = shouldShowApproveButton && !isAllowedToApproveExpenseReport(moneyRequestReport); const canAllowSettlement = hasUpdatedTotal(moneyRequestReport, policy); @@ -94,6 +104,10 @@ function PayPrimaryAction({ if (!type || !chatReport) { return; } + if (shouldBlockDirectPayment(type)) { + showNonReimbursablePaymentErrorModal(); + return; + } if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else if (isAnyTransactionOnHold) { @@ -149,26 +163,29 @@ function PayPrimaryAction({ }; return ( - + <> + + {nonReimbursablePaymentErrorDecisionModal} + ); } diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index c4db37bd52b7a..697152d63a7f6 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -280,14 +280,14 @@ function TransactionItemRow({ const totalPerAttendee = useMemo(() => { const attendeesCount = transactionAttendees.length ?? 0; - const totalAmount = getAmount(transactionItem); + const totalAmount = getAmount(transactionItem, isExpenseReport(report)); if (!attendeesCount || totalAmount === undefined) { return undefined; } return totalAmount / attendeesCount; - }, [transactionAttendees.length, transactionItem]); + }, [report, transactionAttendees.length, transactionItem]); const renderColumn = (column: SearchColumnType): React.ReactNode => { switch (column) { diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index bc9e3271e2ec9..50ff0926e61a5 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -200,6 +200,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac selectedTransactionIDs: transactionIDs, allTransactionViolationsParam: transactionViolations, currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail: currentUserPersonalDetails.email ?? '', }); deletedTransactionIDs.push(transactionID); if (action.childReportID) { diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index a53e1511405c5..a162e077c071a 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -872,6 +872,7 @@ type DeleteMoneyRequestFunctionParams = { selectedTransactionIDs?: string[]; allTransactionViolationsParam: OnyxCollection; currentUserAccountID: number; + currentUserEmail: string; }; type PayMoneyRequestFunctionParams = { @@ -8608,6 +8609,7 @@ function deleteMoneyRequest({ selectedTransactionIDs, allTransactionViolationsParam, currentUserAccountID, + currentUserEmail, }: DeleteMoneyRequestFunctionParams) { if (!transactionID) { return; @@ -8691,14 +8693,7 @@ function deleteMoneyRequest({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, value: { - hasOutstandingChildRequest: hasOutstandingChildRequest( - chatReport, - updatedIOUReport, - deprecatedCurrentUserEmail, - currentUserAccountID, - allTransactionViolationsParam, - undefined, - ), + hasOutstandingChildRequest: hasOutstandingChildRequest(chatReport, updatedIOUReport, currentUserEmail, currentUserAccountID, allTransactionViolationsParam, undefined), }, }); } @@ -8717,14 +8712,7 @@ function deleteMoneyRequest({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, value: { - hasOutstandingChildRequest: hasOutstandingChildRequest( - chatReport, - iouReport?.reportID, - deprecatedCurrentUserEmail, - currentUserAccountID, - allTransactionViolationsParam, - undefined, - ), + hasOutstandingChildRequest: hasOutstandingChildRequest(chatReport, iouReport?.reportID, currentUserEmail, currentUserAccountID, allTransactionViolationsParam, undefined), iouReportID: null, ...optimisticLastReportData, }, @@ -8936,6 +8924,7 @@ function deleteTrackExpense({ isSingleTransactionView, allTransactionViolationsParam, currentUserAccountID, + currentUserEmail: deprecatedCurrentUserEmail, }); return urlToNavigateBack; } diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 86cc38536e558..fa6ec842fcc73 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -948,6 +948,7 @@ function bulkDeleteReports({ selectedTransactionIDs: batchTransactionIDsForReport.length > 0 ? batchTransactionIDsForReport : undefined, allTransactionViolationsParam: transactionsViolations, currentUserAccountID: currentUserAccountIDParam, + currentUserEmail: currentUserEmailParam, }); } diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 1c27992a8f641..7b14eef105004 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -123,6 +123,7 @@ import { getRemovedFromApprovalChainMessage, getRenamedAction, getRenamedCardFeedMessage, + getReportActionHtml, getReportActionMessage, getReportActionText, getSetAutoJoinMessage, @@ -1523,6 +1524,12 @@ function PureReportActionItem({ children = } />; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION) { children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN || action.actionName === CONST.REPORT.ACTIONS.TYPE.CARD_UNFROZEN) { + children = ( + + ${getReportActionHtml(action)}`} /> + + ); } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION)) { children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES)) { @@ -1871,7 +1878,6 @@ function PureReportActionItem({ draftMessage={draftMessage} reportID={reportID} originalReportID={originalReportID} - policyID={report?.policyID} index={index} ref={composerTextInputRef} shouldDisableEmojiPicker={ diff --git a/src/pages/inbox/report/ReportActionCompose/AgentZeroAwareTypingIndicator.tsx b/src/pages/inbox/report/ReportActionCompose/AgentZeroAwareTypingIndicator.tsx new file mode 100644 index 0000000000000..835cc1323b4f1 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/AgentZeroAwareTypingIndicator.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; +import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; + +function AgentZeroAwareTypingIndicator({reportID}: {reportID: string}) { + const shouldSuppress = useShouldSuppressConciergeIndicators(reportID); + if (shouldSuppress) { + return null; + } + return ; +} + +export default AgentZeroAwareTypingIndicator; diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 949ffdebca882..46d92156765e4 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -1,6 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; import {accountIDSelector} from '@selectors/Session'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {Activity, useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import AttachmentPicker from '@components/AttachmentPicker'; @@ -531,37 +531,39 @@ function AttachmentPickerWithMenuItems({ )} - { - setMenuVisibility(false); - onItemSelected(); - - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === menuItems.length - 1) { - if (isSafari()) { - triggerAttachmentPicker(); - return; + + { + setMenuVisibility(false); + onItemSelected(); + + // In order for the file picker to open dynamically, the click + // function must be called from within a event handler that was initiated + // by the user on Safari. + if (index === menuItems.length - 1) { + if (isSafari()) { + triggerAttachmentPicker(); + return; + } + close(() => { + triggerAttachmentPicker(); + }); } - close(() => { - triggerAttachmentPicker(); - }); - } - }} - anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} - anchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - }} - menuItems={menuItems} - anchorRef={actionButtonRef} - /> + }} + anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + menuItems={menuItems} + anchorRef={actionButtonRef} + /> + ); }} diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx new file mode 100644 index 0000000000000..bce78e937f99e --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; +import useOnyx from '@hooks/useOnyx'; +import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; +import {chatIncludesConcierge} from '@libs/ReportUtils'; +import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; +import ONYXKEYS from '@src/ONYXKEYS'; +import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; +import {useComposerActions, useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; +import useAttachmentPicker from './useAttachmentPicker'; + +type ComposerActionMenuProps = { + reportID: string; +}; + +function ComposerActionMenu({reportID}: ComposerActionMenuProps) { + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {isMenuVisible, isFullComposerAvailable} = useComposerState(); + const {exceededMaxLength} = useComposerSendState(); + const {setMenuVisibility, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker} = useComposerActions(); + const {actionButtonRef} = useComposerMeta(); + + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); + + const {raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); + const isBlockedFromConcierge = chatIncludesConcierge({participants: report?.participants}) && isBlockedFromConciergeUserAction(blockedFromConcierge); + const {pickAttachments, PDFValidationComponent, ErrorModal} = useAttachmentPicker(reportID); + + const reportParticipantIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); + + const shouldFocusComposerOnScreenFocus = canFocusInputOnScreenFocus() || !!draftComment; + + return ( + <> + pickAttachments({files})} + reportID={reportID} + report={report} + currentUserPersonalDetails={currentUserPersonalDetails} + reportParticipantIDs={reportParticipantIDs} + isFullComposerAvailable={isFullComposerAvailable} + isComposerFullSize={isComposerFullSize} + disabled={isBlockedFromConcierge} + setMenuVisibility={setMenuVisibility} + isMenuVisible={isMenuVisible} + onTriggerAttachmentPicker={onTriggerAttachmentPicker} + raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLayoutTriggered} + onAddActionPressed={onAddActionPressed} + onItemSelected={onItemSelected} + onCanceledAttachmentPicker={() => { + if (!shouldFocusComposerOnScreenFocus) { + return; + } + focus(); + }} + actionButtonRef={actionButtonRef} + shouldDisableAttachmentItem={!!exceededMaxLength} + /> + {PDFValidationComponent} + {ErrorModal} + + ); +} + +export default ComposerActionMenu; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx new file mode 100644 index 0000000000000..f4bc3fc4d2c62 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import {View} from 'react-native'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; + +type ComposerBoxProps = { + reportID: string; + children: React.ReactNode; +}; + +function ComposerBox({reportID, children}: ComposerBoxProps) { + const styles = useThemeStyles(); + const {isFocused} = useComposerState(); + const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); + const {containerRef} = useComposerMeta(); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); + const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; + + return ( + + + {children} + + + ); +} + +export default ComposerBox; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts new file mode 100644 index 0000000000000..3378fb68bbd04 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -0,0 +1,152 @@ +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 {Mention} from '@components/MentionSuggestions'; +import type {FileObject} from '@src/types/utils/Attachment'; +import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; + +type SuggestionsRef = { + resetSuggestions: () => void; + onSelectionChange?: (event: TextInputSelectionChangeEvent) => void; + triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; + updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; + setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; + getSuggestions: () => Mention[] | Emoji[]; + getIsSuggestionsMenuVisible: () => boolean; +}; + +// Hot — changes on every keystroke +type ComposerText = string; + +// Warm — changes on interaction +type ComposerState = { + isFocused: boolean; + isMenuVisible: boolean; + isFullComposerAvailable: boolean; +}; + +// Warm — changes based on content + policy +type ComposerSendState = { + isSendDisabled: boolean; + exceededMaxLength: number | null; + hasExceededMaxTaskTitleLength: boolean; + isBlockedFromConcierge: boolean; +}; + +// Frozen — stable references, never changes after mount +type ComposerActions = { + setValue: (v: string) => void; + setMenuVisibility: (v: boolean) => void; + setIsFullComposerAvailable: (v: boolean) => void; + setComposerRef: (ref: ComposerRef | null) => void; + focus: () => void; + onBlur: (event: BlurEvent) => void; + onFocus: () => void; + onAddActionPressed: () => void; + onItemSelected: () => void; + onTriggerAttachmentPicker: () => void; + clearComposer: () => void; +}; + +// Infrequent — changes only when send logic changes +type ComposerSendActions = { + handleSendMessage: () => void; + onValueChange: (value: string) => void; +}; + +// Frozen — stable refs, set once +type ComposerMeta = { + containerRef: RefObject; + composerRef: RefObject; + suggestionsRef: RefObject; + actionButtonRef: RefObject; + isNextModalWillOpenRef: RefObject; + attachmentFileRef: RefObject; +}; + +const noop = () => {}; + +const ComposerTextContext = createContext(''); + +const defaultState: ComposerState = { + isFocused: false, + isMenuVisible: false, + isFullComposerAvailable: false, +}; +const ComposerStateContext = createContext(defaultState); + +const defaultSendState: ComposerSendState = { + isSendDisabled: true, + exceededMaxLength: null, + hasExceededMaxTaskTitleLength: false, + isBlockedFromConcierge: false, +}; +const ComposerSendStateContext = createContext(defaultSendState); + +const defaultActions: ComposerActions = { + setValue: noop, + setMenuVisibility: noop, + setIsFullComposerAvailable: noop, + setComposerRef: noop, + focus: noop, + onBlur: noop, + onFocus: noop, + onAddActionPressed: noop, + onItemSelected: noop, + onTriggerAttachmentPicker: noop, + clearComposer: noop, +}; +const ComposerActionsContext = createContext(defaultActions); + +const defaultSendActions: ComposerSendActions = { + handleSendMessage: noop, + onValueChange: noop, +}; +const ComposerSendActionsContext = createContext(defaultSendActions); + +const ComposerMetaContext = createContext(null); + +function useComposerText() { + return useContext(ComposerTextContext); +} + +function useComposerState() { + return useContext(ComposerStateContext); +} + +function useComposerSendState() { + return useContext(ComposerSendStateContext); +} + +function useComposerActions() { + return useContext(ComposerActionsContext); +} + +function useComposerSendActions() { + return useContext(ComposerSendActionsContext); +} + +function useComposerMeta() { + const ctx = useContext(ComposerMetaContext); + if (!ctx) { + throw new Error('useComposerMeta must be used inside ComposerProvider'); + } + return ctx; +} + +export { + ComposerTextContext, + ComposerStateContext, + ComposerSendStateContext, + ComposerActionsContext, + ComposerSendActionsContext, + ComposerMetaContext, + useComposerText, + useComposerState, + useComposerSendState, + useComposerActions, + useComposerSendActions, + useComposerMeta, +}; +export type {SuggestionsRef, ComposerText, ComposerState, ComposerSendState, ComposerActions, ComposerSendActions, ComposerMeta}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx new file mode 100644 index 0000000000000..e12b4d2901ec7 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerDropZone.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; +import DropZoneUI from '@components/DropZone/DropZoneUI'; +import DualDropZone from '@components/DropZone/DualDropZone'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePreferredPolicy from '@hooks/usePreferredPolicy'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getParentReport, isChatRoom, isGroupChat, isInvoiceReport, isReportApproved, isSettled, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; +import {hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useAttachmentPicker from './useAttachmentPicker'; +import useReceiptDrop from './useReceiptDrop'; +import useShouldAddOrReplaceReceipt from './useShouldAddOrReplaceReceipt'; + +type ComposerDropZoneProps = { + /** The ID of the report */ + reportID: string; + + /** Content to wrap with the drop zone */ + children: React.ReactNode; +}; + +type RichDropZoneProps = { + /** The ID of the report */ + reportID: string; + + /** Whether the current view allows adding or replacing a receipt */ + shouldAddOrReplaceReceipt: boolean; + + /** The transaction ID relevant to this report, if any */ + transactionID: string | undefined; + + /** Callback when an attachment file is dropped */ + onAttachmentDrop: (dragEvent: DragEvent) => void; + + /** Callback when a receipt file is dropped */ + onReceiptDrop: (dragEvent: DragEvent) => void; + + /** Content to wrap with the drop zone */ + children: React.ReactNode; +}; + +function SimpleDropZone({onAttachmentDrop, children}: {onAttachmentDrop: (dragEvent: DragEvent) => void; children: React.ReactNode}) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); + + return ( + <> + {children} + + + + + ); +} + +function RichDropZone({reportID, shouldAddOrReplaceReceipt, transactionID, onAttachmentDrop, onReceiptDrop, children}: RichDropZoneProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); + const isReportArchived = useReportIsArchived(report?.reportID); + const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); + + const reportParticipantIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); + + const hasReceipt = hasReceiptTransactionUtils(transaction); + + const parentReport = getParentReport(report); + const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); + const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; + const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; + const shouldDisplayDualDropZone = canModifyReceipt || hasMoneyRequestOptions; + + if (shouldDisplayDualDropZone) { + return ( + <> + {children} + + + ); + } + + return ( + <> + {children} + + + + + ); +} + +function ComposerDropZone({reportID, children}: ComposerDropZoneProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const {isOffline} = useNetwork(); + const {shouldAddOrReplaceReceipt, transactionID} = useShouldAddOrReplaceReceipt(reportID, isOffline); + const {pickAttachments, PDFValidationComponent: AttachmentPDFValidation, ErrorModal: AttachmentErrorModal} = useAttachmentPicker(reportID); + const { + onReceiptDropped, + PDFValidationComponent: ReceiptPDFValidation, + ErrorModal: ReceiptErrorModal, + } = useReceiptDrop({ + reportID, + report, + shouldAddOrReplaceReceipt, + transactionID, + }); + + const onAttachmentDrop = (dragEvent: DragEvent) => pickAttachments({dragEvent}); + + // Cheap gate: rooms, groups, and invoices never show the dual drop zone. + // ~60% of chats hit this path with zero extra subscriptions. + if (isChatRoom(report) || isGroupChat(report) || isInvoiceReport(report)) { + return ( + <> + {children} + {AttachmentPDFValidation} + {AttachmentErrorModal} + {ReceiptPDFValidation} + {ReceiptErrorModal} + + ); + } + + return ( + <> + + {children} + + {AttachmentPDFValidation} + {AttachmentErrorModal} + {ReceiptPDFValidation} + {ReceiptErrorModal} + + ); +} + +export default ComposerDropZone; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx new file mode 100644 index 0000000000000..e177b763d1117 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx @@ -0,0 +1,70 @@ +import React, {useEffect} from 'react'; +import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import DomUtils from '@libs/DomUtils'; +import {chatIncludesConcierge} from '@libs/ReportUtils'; +import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; +import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {useComposerActions, useComposerMeta} from './ComposerContext'; + +type ComposerEmojiPickerProps = { + reportID: string; +}; + +function ComposerEmojiPicker({reportID}: ComposerEmojiPickerProps) { + const styles = useThemeStyles(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isMediumScreenWidth} = useResponsiveLayout(); + const {focus} = useComposerActions(); + const {composerRef} = useComposerMeta(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); + const isBlockedFromConcierge = chatIncludesConcierge({participants: report?.participants}) && isBlockedFromConciergeUserAction(blockedFromConcierge); + + const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; + const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; + const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; + const emojiShiftVertical = reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; + + // Hide emoji picker on unmount or when switching reports + useEffect( + () => () => { + if (!isActiveEmojiPickerAction(reportID)) { + return; + } + hideEmojiPicker(); + }, + [reportID], + ); + + if (canUseTouchScreen() && isMediumScreenWidth) { + return null; + } + + return ( + { + if (isNavigating) { + return; + } + const activeElementId = DomUtils.getActiveElement()?.id; + if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { + return; + } + focus(); + }} + onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} + emojiPickerID={reportID} + shiftVertical={emojiShiftVertical} + /> + ); +} + +export default ComposerEmojiPicker; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx new file mode 100644 index 0000000000000..52fb4c2e33f31 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import {View} from 'react-native'; +import ExceededCommentLength from '@components/ExceededCommentLength'; +import OfflineIndicator from '@components/OfflineIndicator'; +import useNetwork from '@hooks/useNetwork'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import AgentZeroAwareTypingIndicator from './AgentZeroAwareTypingIndicator'; +import {useComposerSendState} from './ComposerContext'; + +type ComposerFooterProps = { + reportID: string; +}; + +function ComposerFooter({reportID}: ComposerFooterProps) { + const styles = useThemeStyles(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const {isOffline} = useNetwork(); + const {exceededMaxLength, hasExceededMaxTaskTitleLength} = useComposerSendState(); + + return ( + + {!shouldUseNarrowLayout && } + + {!!exceededMaxLength && ( + + )} + + ); +} + +export default ComposerFooter; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx new file mode 100644 index 0000000000000..83451b62bba27 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInputWrapper.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import type {MeasureInWindowOnSuccessCallback} from 'react-native'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import FS from '@libs/Fullstory'; +import { + canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + chatIncludesChronos, + chatIncludesConcierge, + isMoneyRequestReport, + isReportTransactionThread, +} from '@libs/ReportUtils'; +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 ComposerWithSuggestions from './ComposerWithSuggestions'; +import useAttachmentPicker from './useAttachmentPicker'; +import useComposerSubmit from './useComposerSubmit'; + +const AI_PLACEHOLDER_KEYS = ['reportActionCompose.askConciergeToUpdate', 'reportActionCompose.askConciergeToCorrect', 'reportActionCompose.askConciergeForHelp'] as const; + +function getRandomPlaceholder(translate: LocalizedTranslate): string { + const randomIndex = Math.floor(Math.random() * AI_PLACEHOLDER_KEYS.length); + return translate(AI_PLACEHOLDER_KEYS[randomIndex]); +} + +type ComposerInputWrapperProps = { + reportID: string; +}; + +function ComposerInputWrapper({reportID}: ComposerInputWrapperProps) { + const {translate, preferredLocale} = useLocalize(); + const {isMenuVisible} = useComposerState(); + const {isBlockedFromConcierge} = useComposerSendState(); + const {pickAttachments, PDFValidationComponent, ErrorModal} = useAttachmentPicker(reportID); + const {setIsFullComposerAvailable, onBlur, onFocus, setComposerRef} = useComposerActions(); + const {handleSendMessage, onValueChange} = useComposerSendActions(); + const {containerRef, suggestionsRef, isNextModalWillOpenRef, attachmentFileRef} = useComposerMeta(); + + 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 measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { + containerRef.current?.measureInWindow(callback); + }; + + const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const isReportArchived = useReportIsArchived(report?.reportID); + const {submitForm} = useComposerSubmit({report, reportID, attachmentFileRef}); + + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); + const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; + const isExpenseRelatedReport = isReportTransactionThread(report) || isMoneyRequestReport(report); + const isEnglishLocale = (preferredLocale ?? CONST.LOCALES.DEFAULT) === CONST.LOCALES.EN; + const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); + + const inputPlaceholder = (() => { + if (includesConcierge && userBlockedFromConcierge) { + return translate('reportActionCompose.blockedFromConcierge'); + } + if (isExpenseRelatedReport && canUserPerformWriteAction && isEnglishLocale) { + return getRandomPlaceholder(translate); + } + return translate('reportActionCompose.writeSomething'); + })(); + const fsClass = report ? FS.getChatFSClass(report) : undefined; + + return ( + <> + pickAttachments({files})} + onClear={submitForm} + disabled={isBlockedFromConcierge || isEmojiPickerVisible()} + onEnterKeyPress={handleSendMessage} + shouldShowComposeInput={shouldShowComposeInput} + onFocus={onFocus} + onBlur={onBlur} + measureParentContainer={measureContainer} + onValueChange={onValueChange} + forwardedFSClass={fsClass} + /> + {PDFValidationComponent} + {ErrorModal} + + ); +} + +export default ComposerInputWrapper; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx new file mode 100644 index 0000000000000..72f005281de0d --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import {canShowReportRecipientLocalTime, getReportOfflinePendingActionAndErrors, getReportRecipientAccountIDs} from '@libs/ReportUtils'; +import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type ComposerLocalTimeProps = { + reportID: string; +}; + +function ComposerLocalTime({reportID}: ComposerLocalTimeProps) { + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalDetails = usePersonalDetails(); + const {isOffline} = useNetwork(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); + + const shouldShow = canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; + const reportRecipientAccountIDs = getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); + const reportRecipient = personalDetails?.[reportRecipientAccountIDs[0]]; + + if (!shouldShow || isEmptyObject(reportRecipient) || isOffline) { + return null; + } + + return ( + + + + ); +} + +export default ComposerLocalTime; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx new file mode 100644 index 0000000000000..64cdd57319242 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -0,0 +1,195 @@ +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 canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; +import {chatIncludesConcierge} from '@libs/ReportUtils'; +import {setIsComposerFullSize} from '@userActions/Report'; +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 type {SuggestionsRef} from './ComposerContext'; +import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import useComposerFocus from './useComposerFocus'; + +const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); + +type ComposerProviderProps = { + reportID: string; + children: React.ReactNode; +}; + +function ComposerProvider({children, reportID}: ComposerProviderProps) { + 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 && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; + + const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); + const [isMenuVisible, setMenuVisibility] = useState(false); + + const [value, setValue] = useState(() => { + return draftComment ?? ''; + }); + + const isEmpty = !value || !!value.match(CONST.REGEX.EMPTY_COMMENT); + + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); + const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); + const isBlockedFromConcierge = includesConcierge && userBlockedFromConcierge; + + const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength(); + const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength(); + + let exceededMaxLength: number | null = null; + if (hasExceededMaxTaskTitleLength) { + exceededMaxLength = CONST.TITLE_CHARACTER_LIMIT; + } else if (hasExceededMaxCommentLength) { + exceededMaxLength = CONST.MAX_COMMENT_LENGTH; + } + + const isSendDisabled = isEmpty || isBlockedFromConcierge || !!exceededMaxLength; + + 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 debouncedValidate = lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}); + + const containerRef = useRef(null); + const suggestionsRef = useRef(null); + const composerRef = useRef(null); + const actionButtonRef = useRef(null); + const attachmentFileRef = useRef(null); + + const composerRefShared = useSharedValue>({}); + + const {isFocused, onBlur, onFocus, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker, isNextModalWillOpenRef} = useComposerFocus({ + composerRef, + suggestionsRef, + actionButtonRef, + initialFocused, + }); + + const clearComposer = () => { + const clearWorklet = composerRefShared.get().clearWorklet; + if (!clearWorklet) { + throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); + } + 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) => { + composerRef.current = ref; + composerRefShared.set({ + clearWorklet: ref?.clearWorklet, + }); + }; + + const onValueChange = (v: string) => { + if (v.length === 0 && isComposerFullSize) { + setIsComposerFullSize(reportID, false); + } + debouncedValidate(v); + }; + + const text = value; + + const composerState = { + isFocused, + isMenuVisible, + isFullComposerAvailable, + }; + + const composerSendState = { + isSendDisabled, + exceededMaxLength, + hasExceededMaxTaskTitleLength, + isBlockedFromConcierge, + }; + + const composerActions = { + setValue, + setMenuVisibility, + setIsFullComposerAvailable, + setComposerRef, + focus, + onBlur, + onFocus, + onAddActionPressed, + onItemSelected, + onTriggerAttachmentPicker, + clearComposer, + }; + + const composerSendActions = { + handleSendMessage, + onValueChange, + }; + + const composerMeta = { + containerRef, + composerRef, + suggestionsRef, + actionButtonRef, + isNextModalWillOpenRef, + attachmentFileRef, + }; + + return ( + + + + + + {children} + + + + + + ); +} + +export default ComposerProvider; +export type {ComposerProviderProps}; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx new file mode 100644 index 0000000000000..1114205167959 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import {useComposerSendActions, useComposerSendState} from './ComposerContext'; +import SendButton from './SendButton'; + +function ComposerSendButton() { + const {isSendDisabled} = useComposerSendState(); + const {handleSendMessage} = useComposerSendActions(); + + return ( + + ); +} + +export default ComposerSendButton; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 5b957d6a45d3d..b9b3442ce5c9d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -36,7 +36,7 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {forceClearInput} from '@libs/ComponentUtils'; import {canSkipTriggerHotkeys, findCommonSuffixLength, insertText, insertWhiteSpaceAtIndex} from '@libs/ComposerUtils'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; -import {containsOnlyEmojis, extractEmojis, getAddedEmojis, getTextVSCursorOffset, insertTextVSBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; +import {containsOnlyEmojis, getAddedEmojis, getTextVSCursorOffset, insertTextVSBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import getPlatform from '@libs/getPlatform'; @@ -47,11 +47,13 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import {isValidReportIDFromPath, shouldAutoFocusOnKeyPress} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; +import type {SuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +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 {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import type {OnEmojiSelected} from '@userActions/EmojiPickerAction'; import {inputFocusChange} from '@userActions/InputFocus'; @@ -60,7 +62,6 @@ 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 @@ -111,9 +112,6 @@ type ComposerWithSuggestionsProps = Partial & /** Whether the input is disabled, defaults to false */ disabled?: boolean; - /** Function to set whether the comment is empty */ - setIsCommentEmpty: (isCommentEmpty: boolean) => void; - /** Function to handle sending a message */ onEnterKeyPress: () => void; @@ -135,9 +133,6 @@ type ComposerWithSuggestionsProps = Partial & /** The ref to the next modal will open */ isNextModalWillOpenRef: RefObject; - /** The last report action */ - lastReportAction?: OnyxEntry; - /** Whether to include chronos */ includeChronos?: boolean; @@ -147,9 +142,6 @@ type ComposerWithSuggestionsProps = Partial & /** policy ID of the report */ policyID?: string; - /** Whether the main composer was hidden */ - didHideComposerInput?: boolean; - /** Reference to the outer element */ ref?: Ref; }; @@ -213,7 +205,6 @@ function ComposerWithSuggestions({ // Props: Report reportID, includeChronos, - lastReportAction, isGroupPolicyReport, policyID, @@ -229,7 +220,6 @@ function ComposerWithSuggestions({ inputPlaceholder, onPasteFile, disabled, - setIsCommentEmpty, onEnterKeyPress, shouldShowComposeInput, measureParentContainer = () => {}, @@ -245,11 +235,11 @@ function ComposerWithSuggestions({ // For testing children, - didHideComposerInput, // Fullstory forwardedFSClass, }: ComposerWithSuggestionsProps) { + const lastReportAction = useLastEditableAction(reportID); const route = useRoute(); const {isKeyboardShown} = useKeyboardState(); const theme = useTheme(); @@ -263,13 +253,8 @@ function ComposerWithSuggestions({ const mobileInputScrollPosition = useRef(0); const cursorPositionValue = useSharedValue({x: 0, y: 0}); const tag = useSharedValue(-1); - const [draftComment = ''] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); - const [value, setValue] = useState(() => { - if (draftComment) { - emojisPresentBefore.current = extractEmojis(draftComment); - } - return draftComment; - }); + const value = useComposerText(); + const {setValue} = useComposerActions(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const commentRef = useRef(value); @@ -291,7 +276,7 @@ function ComposerWithSuggestions({ const {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const shouldAutoFocus = (shouldFocusInputOnScreenFocus || !!draftComment) && shouldShowComposeInput && areAllModalsHidden() && isFocused && !didHideComposerInput; + const shouldAutoFocus = (shouldFocusInputOnScreenFocus || !!value) && shouldShowComposeInput && areAllModalsHidden() && isFocused; const delayedAutoFocusRouteKeyRef = useRef(null); const valueRef = useRef(value); @@ -453,13 +438,6 @@ function ComposerWithSuggestions({ } } const newCommentConverted = convertToLTRForComposer(newComment); - const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/); - const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); - - /** Only update isCommentEmpty state if it's different from previous one */ - if (isNewCommentEmpty !== isPrevCommentEmpty) { - setIsCommentEmpty(isNewCommentEmpty); - } emojisPresentBefore.current = emojis; setValue(newCommentConverted); @@ -495,7 +473,7 @@ function ComposerWithSuggestions({ preferredLocale, preferredSkinTone, reportID, - setIsCommentEmpty, + setValue, suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, @@ -805,7 +783,7 @@ function ComposerWithSuggestions({ // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. // We avoid doing this on native platforms since the software keyboard popping // open creates a jarring and broken UX. - if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !isModalVisible && (!!prevIsModalVisible || !prevIsFocused))) { + if (!(willBlurTextInputOnTapOutside && !isNextModalWillOpenRef.current && !isModalVisible && (!!prevIsModalVisible || !prevIsFocused))) { return; } @@ -814,7 +792,7 @@ function ComposerWithSuggestions({ return; } focus(true); - }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal?.isVisible, isNextModalWillOpenRef, shouldAutoFocus, isSidePanelHiddenOrLargeScreen]); + }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal?.isVisible, isNextModalWillOpenRef, isSidePanelHiddenOrLargeScreen]); useEffect(() => { // Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit @@ -988,8 +966,6 @@ function ComposerWithSuggestions({ isComposerFocused={textInputRef.current?.isFocused()} updateComment={updateComment} measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} - isGroupPolicyReport={isGroupPolicyReport} - policyID={policyID} // Input value={value} selection={selection} diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 7f3600739379d..bef0e5d56929e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,834 +1,65 @@ -import {useRoute} from '@react-navigation/native'; -import {Str} from 'expensify-common'; -import lodashDebounce from 'lodash/debounce'; -import noop from 'lodash/noop'; -import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import type {BlurEvent, MeasureInWindowOnSuccessCallback, TextInputSelectionChangeEvent} from 'react-native'; +import React from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {useSharedValue} from 'react-native-reanimated'; -import {scheduleOnUI} from 'react-native-worklets'; -import type {Emoji} from '@assets/emojis/types'; -import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; -import DropZoneUI from '@components/DropZone/DropZoneUI'; -import DualDropZone from '@components/DropZone/DualDropZone'; -import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; -import ExceededCommentLength from '@components/ExceededCommentLength'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; -import type {LocalizedTranslate} from '@components/LocaleContextProvider'; -import type {Mention} from '@components/MentionSuggestions'; -import OfflineIndicator from '@components/OfflineIndicator'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import useAncestors from '@hooks/useAncestors'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; -import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; -import useIsInSidePanel from '@hooks/useIsInSidePanel'; -import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; -import useParentReportAction from '@hooks/useParentReportAction'; -import usePreferredPolicy from '@hooks/usePreferredPolicy'; -import useReportIsArchived from '@hooks/useReportIsArchived'; -import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useShortMentionsList from '@hooks/useShortMentionsList'; -import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {addComment} from '@libs/actions/Report'; -import {createTaskAndNavigate, setNewOptimisticAssignee} from '@libs/actions/Task'; -import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; -import ComposerFocusManager from '@libs/ComposerFocusManager'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import DomUtils from '@libs/DomUtils'; -import FS from '@libs/Fullstory'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {isEmailPublicDomain} from '@libs/LoginUtils'; -import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import {rand64} from '@libs/NumberUtils'; -import {addDomainToShortMention} from '@libs/ParsingUtils'; -import { - getCombinedReportActions, - getFilteredReportActionsForReportView, - getLinkedTransactionID, - getOneTransactionThreadReportID, - getReportAction, - isMoneyRequestAction, - isSentMoneyReportAction, -} from '@libs/ReportActionsUtils'; -import { - canEditFieldOfMoneyRequest, - canEditReportAction, - canShowReportRecipientLocalTime, - canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - chatIncludesChronos, - chatIncludesConcierge, - getParentReport, - getReportOfflinePendingActionAndErrors, - getReportRecipientAccountIDs, - isChatRoom, - isGroupChat, - isInvoiceReport, - isMoneyRequestReport, - isReportApproved, - isReportTransactionThread, - isSettled, - temporary_getMoneyRequestOptions, -} from '@libs/ReportUtils'; -import {startSpan} from '@libs/telemetry/activeSpans'; -import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; -import {generateAccountID} from '@libs/UserUtils'; -import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; -import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; -import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; -import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; -import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; -import {addAttachmentWithComment, setIsComposerFullSize} from '@userActions/Report'; -import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; -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 {isEmptyObject} from '@src/types/utils/EmptyObject'; -import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; -import ComposerWithSuggestions from './ComposerWithSuggestions'; +import ComposerActionMenu from './ComposerActionMenu'; +import ComposerBox from './ComposerBox'; +import type {SuggestionsRef} from './ComposerContext'; +import ComposerDropZone from './ComposerDropZone'; +import ComposerEmojiPicker from './ComposerEmojiPicker'; +import ComposerFooter from './ComposerFooter'; +import ComposerInputWrapper from './ComposerInputWrapper'; +import ComposerLocalTime from './ComposerLocalTime'; +import ComposerProvider from './ComposerProvider'; +import ComposerSendButton from './ComposerSendButton'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; -import SendButton from './SendButton'; -import useAttachmentUploadValidation from './useAttachmentUploadValidation'; - -type SuggestionsRef = { - resetSuggestions: () => void; - onSelectionChange?: (event: TextInputSelectionChangeEvent) => void; - triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; - updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; - setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; - getSuggestions: () => Mention[] | Emoji[]; - getIsSuggestionsMenuVisible: () => boolean; -}; type ReportActionComposeProps = { - /** The ID of the report this composer is for */ reportID: string; }; -function AgentZeroAwareTypingIndicator({reportID}: {reportID: string}) { - const shouldSuppress = useShouldSuppressConciergeIndicators(reportID); - if (shouldSuppress) { - return null; - } - return ; -} - -// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will -// prevent auto focus on existing chat for mobile device -const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); - -const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); - -/** - * List of AI-aware placeholder translation keys for expense threads - */ -const AI_PLACEHOLDER_KEYS = ['reportActionCompose.askConciergeToUpdate', 'reportActionCompose.askConciergeToCorrect', 'reportActionCompose.askConciergeForHelp'] as const; - -/** - * Returns a random AI-aware placeholder for expense threads - */ -function getRandomPlaceholder(translate: LocalizedTranslate): string { - const randomIndex = Math.floor(Math.random() * AI_PLACEHOLDER_KEYS.length); - return translate(AI_PLACEHOLDER_KEYS[randomIndex]); -} - -// eslint-disable-next-line import/no-mutable-exports -let onSubmitAction = noop; - -function ReportActionCompose({reportID}: ReportActionComposeProps) { +function Composer({reportID}: ReportActionComposeProps) { const styles = useThemeStyles(); - const theme = useTheme(); - const {translate, preferredLocale} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth, isMediumScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); - const {isOffline} = useNetwork(); - const isInSidePanel = useIsInSidePanel(); - const {kickoffWaitingIndicator} = useAgentZeroStatusActions(); - const actionButtonRef = useRef(null); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const personalDetails = usePersonalDetails(); - const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); - const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); - const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); - const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); - const {availableLoginsList} = useShortMentionsList(); - const currentUserEmail = currentUserPersonalDetails.email ?? ''; - const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); - - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const {isSmallScreenWidth} = useResponsiveLayout(); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); - const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); - const filteredReportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); - - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); - const allReportTransactions = useReportTransactionsCollection(reportID); - const reportTransactions = useMemo( - () => getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true), - [allReportTransactions, filteredReportActions, isOffline], - ); - const visibleTransactions = useMemo( - () => reportTransactions?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), - [reportTransactions, isOffline], - ); - const reportTransactionIDs = useMemo(() => visibleTransactions?.map((t) => t.transactionID), [visibleTransactions]); - const isSentMoneyReport = useMemo(() => filteredReportActions.some((action) => isSentMoneyReportAction(action)), [filteredReportActions]); - const transactionThreadReportID = useMemo( - () => getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs), - [report, chatReport, filteredReportActions, isOffline, reportTransactionIDs], - ); - const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; - - const parentReportAction = useParentReportAction(report); - const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${effectiveTransactionThreadReportID}`); - const transactionThreadReportActionsArray = useMemo(() => (transactionThreadReportActions ? Object.values(transactionThreadReportActions) : []), [transactionThreadReportActions]); - const combinedReportActions = useMemo( - () => getCombinedReportActions(filteredReportActions, effectiveTransactionThreadReportID ?? null, transactionThreadReportActionsArray), - [filteredReportActions, effectiveTransactionThreadReportID, transactionThreadReportActionsArray], - ); - - const route = useRoute(); - const isOnSearchMoneyRequestReport = route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT || route.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT; - - // On the search money request report page (MoneyRequestReportView), lastReportAction uses only - // the parent report's actions — not combined with transaction thread actions. The table view - // doesn't display transaction thread comments inline, so the last editable action should only - // come from what's visible in the parent report. ReportScreen (inbox) uses combinedReportActions - // because ReportActionsView merges thread comments into the visible list, and up-arrow-to-edit - // should be able to reach those comments. - const actionsForLastEditable = isOnSearchMoneyRequestReport ? filteredReportActions : combinedReportActions; - const lastReportAction = useMemo( - () => [...actionsForLastEditable, parentReportAction].find((action) => !isMoneyRequestAction(action) && canEditReportAction(action, undefined)), - [actionsForLastEditable, parentReportAction], - ); - - const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); - - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); - const [initialModalState] = useOnyx(ONYXKEYS.MODAL); - const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); - const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); - const [betas] = useOnyx(ONYXKEYS.BETAS); - - const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; - - const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${effectiveTransactionThreadReportID ?? reportID}`); - const reportAncestors = useAncestors(report); - const targetReportAncestors = useAncestors(targetReport); - const {scrollOffsetRef} = useContext(ActionListContext); - - /** - * Updates the Highlight state of the composer - */ - const [isFocused, setIsFocused] = useState(() => { - return shouldFocusComposerOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; - }); - - const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); - - const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - - const [isCommentEmpty, setIsCommentEmpty] = useState(() => { - return !draftComment || !!draftComment.match(CONST.REGEX.EMPTY_COMMENT); - }); - - /** - * Updates the visibility state of the menu - */ - const [isMenuVisible, setMenuVisibility] = useState(false); - const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); - const [didHideComposerInput, setDidHideComposerInput] = useState(!shouldShowComposeInput); - - /** - * Updates the composer when the comment length is exceeded - * Shows red borders and prevents the comment from being sent - */ - const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength(); - const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength(); - const [exceededMaxLength, setExceededMaxLength] = useState(null); - - const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); - - const suggestionsRef = useRef(null); - const composerRef = useRef(null); - const reportParticipantIDs = useMemo( - () => - Object.keys(report?.participants ?? {}) - .map(Number) - .filter((accountID) => accountID !== currentUserPersonalDetails.accountID), - [currentUserPersonalDetails.accountID, report?.participants], - ); - - const shouldShowReportRecipientLocalTime = useMemo( - () => canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize, - [personalDetails, report, currentUserPersonalDetails.accountID, isComposerFullSize], - ); - - const includesConcierge = useMemo(() => chatIncludesConcierge({participants: report?.participants}), [report?.participants]); - const userBlockedFromConcierge = useMemo(() => isBlockedFromConciergeUserAction(blockedFromConcierge), [blockedFromConcierge]); - const isBlockedFromConcierge = useMemo(() => includesConcierge && userBlockedFromConcierge, [includesConcierge, userBlockedFromConcierge]); - const isReportArchived = useReportIsArchived(report?.reportID); - const isTransactionThreadView = useMemo(() => isReportTransactionThread(report), [report]); - const isExpensesReport = useMemo(() => reportTransactions && reportTransactions.length > 1, [reportTransactions]); - - const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { - canEvict: false, - }); - - const iouAction = rawReportActions ? Object.values(rawReportActions).find((action) => isMoneyRequestAction(action)) : null; - const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; - - const transactionID = useMemo(() => getTransactionID(report) ?? linkedTransactionID, [report, linkedTransactionID]); - - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); - - const isSingleTransactionView = useMemo(() => !!transaction && !!reportTransactions && reportTransactions.length === 1, [transaction, reportTransactions]); - const effectiveParentReportAction = isSingleTransactionView ? iouAction : getReportAction(report?.parentReportID, report?.parentReportActionID); - const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); - const canEditReceipt = - canUserPerformWriteAction && - canEditFieldOfMoneyRequest({reportAction: effectiveParentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction}) && - !transaction?.receipt?.isTestDriveReceipt; - const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; - - const hasReceipt = useMemo(() => hasReceiptTransactionUtils(transaction), [transaction]); - - const shouldDisplayDualDropZone = useMemo(() => { - const parentReport = getParentReport(report); - const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); - const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; - const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; - const isRoomOrGroupChat = isChatRoom(report) || isGroupChat(report); - return !isRoomOrGroupChat && (canModifyReceipt || hasMoneyRequestOptions) && !isInvoiceReport(report); - }, [shouldAddOrReplaceReceipt, report, reportParticipantIDs, policy, isReportArchived, isRestrictedToPreferredPolicy, betas]); - - // Check if this is an expense-related report (IOU, expense report, or transaction thread) - const isExpenseRelatedReport = useMemo(() => isTransactionThreadView || isMoneyRequestReport(report), [isTransactionThreadView, report]); - - const isEnglishLocale = (preferredLocale ?? CONST.LOCALES.DEFAULT) === CONST.LOCALES.EN; - - // Placeholder to display in the chat input. - const inputPlaceholder = useMemo(() => { - if (includesConcierge && userBlockedFromConcierge) { - return translate('reportActionCompose.blockedFromConcierge'); - } - - // Only English should get AI-specific ghost text. - if (isExpenseRelatedReport && canUserPerformWriteAction && isEnglishLocale) { - return getRandomPlaceholder(translate); - } - - return translate('reportActionCompose.writeSomething'); - }, [includesConcierge, translate, userBlockedFromConcierge, isExpenseRelatedReport, canUserPerformWriteAction, isEnglishLocale]); - - const focus = () => { - if (composerRef.current === null) { - return; - } - composerRef.current?.focus(true); - }; - - const isKeyboardVisibleWhenShowingModalRef = useRef(false); - const isNextModalWillOpenRef = useRef(false); - - const containerRef = useRef(null); - const measureContainer = useCallback( - (callback: MeasureInWindowOnSuccessCallback) => { - if (!containerRef.current) { - return; - } - containerRef.current.measureInWindow(callback); - }, - // We added isComposerFullSize in dependencies so that when this value changes, we recalculate the position of the popup - // eslint-disable-next-line react-hooks/exhaustive-deps - [isComposerFullSize], - ); - - const onAddActionPressed = useCallback(() => { - if (!willBlurTextInputOnTapOutside) { - isKeyboardVisibleWhenShowingModalRef.current = !!composerRef.current?.isFocused(); - } - composerRef.current?.blur(); - }, []); - - const onItemSelected = useCallback(() => { - isKeyboardVisibleWhenShowingModalRef.current = false; - }, []); - - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { - if (!suggestionsRef.current) { - return; - } - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }, []); - - const attachmentFileRef = useRef(null); - - const addAttachment = useCallback((file: FileObject | FileObject[]) => { - attachmentFileRef.current = file; - - const clearWorklet = composerRef.current?.clearWorklet; - - if (!clearWorklet) { - throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); - } - - scheduleOnUI(clearWorklet); - }, []); - - /** - * Event handler to update the state after the attachment preview is closed. - */ - const onAttachmentPreviewClose = useCallback(() => { - updateShouldShowSuggestionMenuToFalse(); - setIsAttachmentPreviewActive(false); - // This enables Composer refocus when the attachments modal is closed by the browser navigation - ComposerFocusManager.setReadyToFocus(); - }, [updateShouldShowSuggestionMenuToFalse]); - - /** - * Add a new comment to this chat - */ - const submitForm = useCallback( - (newComment: string) => { - const newCommentTrimmed = newComment.trim(); - - kickoffWaitingIndicator(); - - if (attachmentFileRef.current) { - addAttachmentWithComment({ - report: targetReport, - notifyReportID: reportID, - ancestors: targetReportAncestors, - attachments: attachmentFileRef.current, - currentUserAccountID: currentUserPersonalDetails.accountID, - text: newCommentTrimmed, - timezone: currentUserPersonalDetails.timezone, - shouldPlaySound: true, - isInSidePanel, - }); - attachmentFileRef.current = null; - } else { - const taskMatch = newCommentTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (taskMatch) { - let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; - if (taskTitle) { - const mention = taskMatch[1] ? taskMatch[1].trim() : ''; - const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); - const mentionWithDomain = addDomainToShortMention(mention, availableLoginsList, currentUserPrivateDomain) ?? mention; - const isValidMention = Str.isValidEmail(mentionWithDomain); - - let assignee: OnyxEntry; - let assigneeChatReport; - if (mentionWithDomain) { - if (isValidMention) { - assignee = Object.values(personalDetails ?? {}).find((value) => value?.login === mentionWithDomain) ?? undefined; - if (!Object.keys(assignee ?? {}).length) { - const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { - accountID: generateAccountID(mentionWithDomain), - login: mentionWithDomain, - }); - assignee = optimisticDataForNewAssignee.assignee; - assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; - } - } else { - taskTitle = `@${mentionWithDomain} ${taskTitle}`; - } - } - createTaskAndNavigate({ - parentReport: report, - title: taskTitle, - description: '', - assigneeEmail: assignee?.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail, - assigneeAccountID: assignee?.accountID, - assigneeChatReport, - policyID: report?.policyID, - isCreatedUsingMarkdown: true, - quickAction, - ancestors: reportAncestors, - }); - return; - } - } - - // Pre-generate the reportActionID so we can correlate the Sentry send-message span with the exact message - const optimisticReportActionID = rand64(); - - // The list is inverted, so an offset near 0 means the user is at the bottom (newest messages visible). - const isScrolledToBottom = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; - if (isScrolledToBottom) { - startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${optimisticReportActionID}`, { - name: 'send-message', - op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, - attributes: { - [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, - [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, - }, - }); - } - addComment({ - report: targetReport, - notifyReportID: reportID, - ancestors: targetReportAncestors, - text: newCommentTrimmed, - timezoneParam: currentUserPersonalDetails.timezone ?? CONST.DEFAULT_TIME_ZONE, - currentUserAccountID: currentUserPersonalDetails.accountID, - shouldPlaySound: true, - isInSidePanel, - reportActionID: optimisticReportActionID, - }); - } - }, - [ - kickoffWaitingIndicator, - targetReport, - report, - reportID, - targetReportAncestors, - reportAncestors, - currentUserPersonalDetails.accountID, - currentUserPersonalDetails.timezone, - isInSidePanel, - currentUserEmail, - availableLoginsList, - personalDetails, - quickAction, - scrollOffsetRef, - ], - ); - - const onTriggerAttachmentPicker = useCallback(() => { - isNextModalWillOpenRef.current = true; - isKeyboardVisibleWhenShowingModalRef.current = true; - }, []); - - const onBlur = useCallback((event: BlurEvent) => { - const webEvent = event as unknown as FocusEvent; - setIsFocused(false); - if (suggestionsRef.current) { - suggestionsRef.current.resetSuggestions(); - } - if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) { - isKeyboardVisibleWhenShowingModalRef.current = true; - } - }, []); - - const onFocus = useCallback(() => { - setIsFocused(true); - }, []); - - useEffect(() => { - if (hasExceededMaxTaskTitleLength) { - setExceededMaxLength(CONST.TITLE_CHARACTER_LIMIT); - } else if (hasExceededMaxCommentLength) { - setExceededMaxLength(CONST.MAX_COMMENT_LENGTH); - } else { - setExceededMaxLength(null); - } - }, [hasExceededMaxTaskTitleLength, hasExceededMaxCommentLength]); - - useEffect(() => { - if (didHideComposerInput || shouldShowComposeInput) { - return; - } - // This is an intentional one-way latch: once the composer input has been hidden, it stays hidden. - // eslint-disable-next-line react-hooks/set-state-in-effect - setDidHideComposerInput(true); - }, [shouldShowComposeInput, didHideComposerInput]); - - // We are returning a callback here as we want to invoke the method on unmount only - useEffect( - () => () => { - if (!isActiveEmojiPickerAction(report?.reportID)) { - return; - } - hideEmojiPicker(); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); - - // When we invite someone to a room they don't have the policy object, but we still want them to be able to mention other reports they are members of, so we only check if the policyID in the report is from a workspace - const isGroupPolicyReport = useMemo(() => !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE, [report?.policyID]); - const reportRecipientAccountIDs = getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); - const reportRecipient = personalDetails?.[reportRecipientAccountIDs[0]]; - const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; - - const hasReportRecipient = !isEmptyObject(reportRecipient); - - const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!exceededMaxLength; - - const validateMaxLength = useCallback( - (value: string) => { - const taskCommentMatch = value?.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(value, {reportID}); - }, - [setHasExceededMaxCommentLength, setHasExceededMaxTitleLength, validateTaskTitleMaxLength, validateCommentMaxLength, reportID], - ); - - const debouncedValidate = useMemo(() => lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}), [validateMaxLength]); - - // Note: using JS refs is not well supported in reanimated, thus we need to store the function in a shared value - // useSharedValue on web doesn't support functions, so we need to wrap it in an object. - const composerRefShared = useSharedValue>({}); - - const handleSendMessage = useCallback(() => { - 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?.(); - }); - }, [isSendDisabled, debouncedValidate, isComposerFullSize, reportID, composerRefShared]); - - onSubmitAction = handleSendMessage; - - const emojiPositionValues = useMemo( - () => ({ - secondaryRowHeight: styles.chatItemComposeSecondaryRow.height, - secondaryRowMarginTop: styles.chatItemComposeSecondaryRow.marginTop, - secondaryRowMarginBottom: styles.chatItemComposeSecondaryRow.marginBottom, - composeBoxMinHeight: styles.chatItemComposeBox.minHeight, - emojiButtonHeight: styles.chatItemEmojiButton.height, - }), - [ - styles.chatItemComposeSecondaryRow.height, - styles.chatItemComposeSecondaryRow.marginTop, - styles.chatItemComposeSecondaryRow.marginBottom, - styles.chatItemComposeBox.minHeight, - styles.chatItemEmojiButton.height, - ], - ); - - const emojiShiftVertical = useMemo(() => { - const chatItemComposeSecondaryRowHeight = emojiPositionValues.secondaryRowHeight + emojiPositionValues.secondaryRowMarginTop + emojiPositionValues.secondaryRowMarginBottom; - const reportActionComposeHeight = emojiPositionValues.composeBoxMinHeight + chatItemComposeSecondaryRowHeight; - const emojiOffsetWithComposeBox = (emojiPositionValues.composeBoxMinHeight - emojiPositionValues.emojiButtonHeight) / 2; - return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; - }, [ - emojiPositionValues.secondaryRowHeight, - emojiPositionValues.secondaryRowMarginTop, - emojiPositionValues.secondaryRowMarginBottom, - emojiPositionValues.composeBoxMinHeight, - emojiPositionValues.emojiButtonHeight, - ]); - - const onValueChange = useCallback( - (value: string) => { - if (value.length === 0 && isComposerFullSize) { - setIsComposerFullSize(reportID, false); - } - debouncedValidate(value); - }, - [isComposerFullSize, reportID, debouncedValidate], - ); - - const {validateAttachments, onReceiptDropped, PDFValidationComponent, ErrorModal} = useAttachmentUploadValidation({ - policy, - reportID, - addAttachment, - onAttachmentPreviewClose, - exceededMaxLength, - shouldAddOrReplaceReceipt, - transactionID, - report, - newParentReport, - currentDate, - currentUserPersonalDetails, - isAttachmentPreviewActive, - setIsAttachmentPreviewActive, - }); - - if (!report) { - return null; - } - - const fsClass = FS.getChatFSClass(report); - return ( - - - {shouldShowReportRecipientLocalTime && hasReportRecipient && } - - - - - {PDFValidationComponent} - validateAttachments({files})} - reportID={reportID} - report={report} - currentUserPersonalDetails={currentUserPersonalDetails} - reportParticipantIDs={reportParticipantIDs} - isFullComposerAvailable={isFullComposerAvailable} - isComposerFullSize={isComposerFullSize} - disabled={isBlockedFromConcierge} - setMenuVisibility={setMenuVisibility} - isMenuVisible={isMenuVisible} - onTriggerAttachmentPicker={onTriggerAttachmentPicker} - raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLayoutTriggered} - onAddActionPressed={onAddActionPressed} - onItemSelected={onItemSelected} - onCanceledAttachmentPicker={() => { - if (!shouldFocusComposerOnScreenFocus) { - return; - } - focus(); - }} - actionButtonRef={actionButtonRef} - shouldDisableAttachmentItem={!!exceededMaxLength} - /> - { - composerRef.current = ref; - composerRefShared.set({ - clearWorklet: ref?.clearWorklet, - }); - }} - suggestionsRef={suggestionsRef} - isNextModalWillOpenRef={isNextModalWillOpenRef} - isScrollLikelyLayoutTriggered={isScrollLayoutTriggered} - raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLayoutTriggered} - reportID={reportID} - policyID={report?.policyID} - includeChronos={chatIncludesChronos(report)} - isGroupPolicyReport={isGroupPolicyReport} - lastReportAction={lastReportAction} - isMenuVisible={isMenuVisible} - inputPlaceholder={inputPlaceholder} - isComposerFullSize={isComposerFullSize} - setIsFullComposerAvailable={setIsFullComposerAvailable} - onPasteFile={(files) => validateAttachments({files})} - onClear={submitForm} - disabled={isBlockedFromConcierge || isEmojiPickerVisible()} - setIsCommentEmpty={setIsCommentEmpty} - onEnterKeyPress={handleSendMessage} - shouldShowComposeInput={shouldShowComposeInput} - onFocus={onFocus} - onBlur={onBlur} - measureParentContainer={measureContainer} - onValueChange={onValueChange} - didHideComposerInput={didHideComposerInput} - forwardedFSClass={fsClass} - /> - {shouldDisplayDualDropZone && ( - validateAttachments({dragEvent})} - onReceiptDrop={onReceiptDropped} - shouldAcceptSingleReceipt={shouldAddOrReplaceReceipt} - /> - )} - {!shouldDisplayDualDropZone && ( - validateAttachments({dragEvent})}> - - - )} - {canUseTouchScreen() && isMediumScreenWidth ? null : ( - { - if (isNavigating) { - return; - } - const activeElementId = DomUtils.getActiveElement()?.id; - if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { - return; - } - focus(); - }} - onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} - emojiPickerID={report?.reportID} - shiftVertical={emojiShiftVertical} - /> - )} - - - {ErrorModal} - - {!shouldUseNarrowLayout && } - - {!!exceededMaxLength && ( - - )} - - - {!isSmallScreenWidth && ( - - - - )} - + + + + + + + + + + + + + + {!isSmallScreenWidth && ( + + + + )} + + ); } -export default memo(ReportActionCompose); -export {onSubmitAction}; +Composer.LocalTime = ComposerLocalTime; +Composer.Box = ComposerBox; +Composer.DropZone = ComposerDropZone; +Composer.ActionMenu = ComposerActionMenu; +Composer.Input = ComposerInputWrapper; +Composer.EmojiPicker = ComposerEmojiPicker; +Composer.SendButton = ComposerSendButton; +Composer.Footer = ComposerFooter; + +export default Composer; export type {SuggestionsRef, ComposerRef, ReportActionComposeProps}; diff --git a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx index 34f2030933c3b..5b0e5a8e41a06 100644 --- a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx @@ -1,4 +1,4 @@ -import React, {memo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import Icon from '@components/Icon'; @@ -79,4 +79,4 @@ function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonP ); } -export default memo(SendButton); +export default SendButton; diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx index 79c55c5ffbc0b..3f2a4205d0944 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import type {Emoji} from '@assets/emojis/types'; import EmojiSuggestions from '@components/EmojiSuggestions'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; @@ -68,134 +68,118 @@ function SuggestionEmoji({ /** * Replace the code of emoji and update selection - * @param {Number} selectedEmoji */ - const insertSelectedEmoji = useCallback( - (highlightedEmojiIndexInner: number) => { - const emojiObject = highlightedEmojiIndexInner !== -1 ? suggestionValues.suggestedEmojis.at(highlightedEmojiIndexInner) : undefined; - if (!emojiObject) { - return; - } + const insertSelectedEmoji = (highlightedEmojiIndexInner: number) => { + const emojiObject = highlightedEmojiIndexInner !== -1 ? suggestionValues.suggestedEmojis.at(highlightedEmojiIndexInner) : undefined; + if (!emojiObject) { + return; + } - const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); - const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); - const isInsideCodeBlock = isPositionInsideCodeBlock(value, suggestionValues.colonIndex); - const emojiOrShortcode = getEmojiCodeForInsertion(emojiObject, preferredSkinTone, isInsideCodeBlock); - - updateComment(`${commentBeforeColon}${emojiOrShortcode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); - - // In some Android phones keyboard, the text to search for the emoji is not cleared - // will be added after the user starts typing again on the keyboard. This package is - // a workaround to reset the keyboard natively. - resetKeyboardInput?.(); - - setSelection({ - start: suggestionValues.colonIndex + emojiOrShortcode.length + CONST.SPACE_LENGTH, - end: suggestionValues.colonIndex + emojiOrShortcode.length + CONST.SPACE_LENGTH, - }); - setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); - }, - [preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], - ); + const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); + const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); + const isInsideCodeBlock = isPositionInsideCodeBlock(value, suggestionValues.colonIndex); + const emojiOrShortcode = getEmojiCodeForInsertion(emojiObject, preferredSkinTone, isInsideCodeBlock); + + updateComment(`${commentBeforeColon}${emojiOrShortcode} ${trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); + + // In some Android phones keyboard, the text to search for the emoji is not cleared + // will be added after the user starts typing again on the keyboard. This package is + // a workaround to reset the keyboard natively. + resetKeyboardInput?.(); + + setSelection({ + start: suggestionValues.colonIndex + emojiOrShortcode.length + CONST.SPACE_LENGTH, + end: suggestionValues.colonIndex + emojiOrShortcode.length + CONST.SPACE_LENGTH, + }); + setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); + }; /** * Clean data related to suggestions */ - const resetSuggestions = useCallback(() => { + const resetSuggestions = () => { setSuggestionValues(defaultSuggestionsValues); - }, []); + }; - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + const updateShouldShowSuggestionMenuToFalse = () => { setSuggestionValues((prevState) => { if (prevState.shouldShowSuggestionMenu) { return {...prevState, shouldShowSuggestionMenu: false}; } return prevState; }); - }, []); + }; /** * Listens for keyboard shortcuts and applies the action */ - const triggerHotkeyActions = useCallback( - (e: KeyboardEvent) => { - const suggestionsExist = suggestionValues.suggestedEmojis.length > 0; - - if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { - e.preventDefault(); - if (suggestionValues.suggestedEmojis.length > 0) { - insertSelectedEmoji(highlightedEmojiIndex); - } - return true; - } + const triggerHotkeyActions = (e: KeyboardEvent) => { + const suggestionsExist = suggestionValues.suggestedEmojis.length > 0; - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { - e.preventDefault(); + if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { + e.preventDefault(); + if (suggestionValues.suggestedEmojis.length > 0) { + insertSelectedEmoji(highlightedEmojiIndex); + } + return true; + } - if (suggestionsExist) { - resetSuggestions(); - } + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + e.preventDefault(); - return true; + if (suggestionsExist) { + resetSuggestions(); } - }, - [highlightedEmojiIndex, insertSelectedEmoji, resetSuggestions, suggestionValues.suggestedEmojis.length], - ); + + return true; + } + }; /** * Calculates and cares about the content of an Emoji Suggester */ - const calculateEmojiSuggestion = useCallback( - (newValue: string, selectionStart?: number, selectionEnd?: number) => { - if (selectionStart !== selectionEnd || !selectionEnd || shouldBlockCalc.current || !newValue || (selectionStart === 0 && selectionEnd === 0)) { - shouldBlockCalc.current = false; - resetSuggestions(); - return; - } - const leftString = newValue.substring(0, selectionEnd); - const colonIndex = leftString.lastIndexOf(':'); + const calculateEmojiSuggestion = (newValue: string, selectionStart?: number, selectionEnd?: number) => { + if (selectionStart !== selectionEnd || !selectionEnd || shouldBlockCalc.current || !newValue || (selectionStart === 0 && selectionEnd === 0)) { + shouldBlockCalc.current = false; + resetSuggestions(); + return; + } + const leftString = newValue.substring(0, selectionEnd); + const colonIndex = leftString.lastIndexOf(':'); - // Skip emoji suggestions if cursor is inside a code block - if (colonIndex !== -1 && isPositionInsideCodeBlock(newValue, colonIndex)) { - resetSuggestions(); - return; - } + // Skip emoji suggestions if cursor is inside a code block + if (colonIndex !== -1 && isPositionInsideCodeBlock(newValue, colonIndex)) { + resetSuggestions(); + return; + } - const isCurrentlyShowingEmojiSuggestion = isEmojiCode(newValue, selectionEnd); + const isCurrentlyShowingEmojiSuggestion = isEmojiCode(newValue, selectionEnd); - const nextState: SuggestionsValue = { - suggestedEmojis: [], - colonIndex, - shouldShowSuggestionMenu: false, - }; - const newSuggestedEmojis = suggestEmojis(leftString, preferredLocale); + const nextState: SuggestionsValue = { + suggestedEmojis: [], + colonIndex, + shouldShowSuggestionMenu: false, + }; + const newSuggestedEmojis = suggestEmojis(leftString, preferredLocale); - if (newSuggestedEmojis?.length && isCurrentlyShowingEmojiSuggestion) { - nextState.suggestedEmojis = newSuggestedEmojis; - nextState.shouldShowSuggestionMenu = !isEmptyObject(newSuggestedEmojis); - } + if (newSuggestedEmojis?.length && isCurrentlyShowingEmojiSuggestion) { + nextState.suggestedEmojis = newSuggestedEmojis; + nextState.shouldShowSuggestionMenu = !isEmptyObject(newSuggestedEmojis); + } - // Early return if there is no update - const currentState = suggestionValuesRef.current; - if (nextState.suggestedEmojis.length === 0 && currentState.suggestedEmojis.length === 0) { - return; - } + // Early return if there is no update + const currentState = suggestionValuesRef.current; + if (nextState.suggestedEmojis.length === 0 && currentState.suggestedEmojis.length === 0) { + return; + } - setSuggestionValues((prevState) => ({...prevState, ...nextState})); - setHighlightedEmojiIndex(0); - }, - [preferredLocale, setHighlightedEmojiIndex, resetSuggestions], - ); + setSuggestionValues((prevState) => ({...prevState, ...nextState})); + setHighlightedEmojiIndex(0); + }; - const debouncedCalculateEmojiSuggestion = useDebounce( - useCallback( - (newValue: string, selectionStart?: number, selectionEnd?: number) => { - calculateEmojiSuggestion(newValue, selectionStart, selectionEnd); - }, - [calculateEmojiSuggestion], - ), - CONST.TIMING.SUGGESTION_DEBOUNCE_TIME, - ); + const debouncedCalculateEmojiSuggestion = useDebounce((newValue: string, selectionStart?: number, selectionEnd?: number) => { + calculateEmojiSuggestion(newValue, selectionStart, selectionEnd); + }, CONST.TIMING.SUGGESTION_DEBOUNCE_TIME); useEffect(() => { if (!isComposerFocused) { @@ -205,29 +189,22 @@ function SuggestionEmoji({ debouncedCalculateEmojiSuggestion(value, selection.start, selection.end); }, [value, selection.start, selection.end, debouncedCalculateEmojiSuggestion, isComposerFocused]); - const setShouldBlockSuggestionCalc = useCallback( - (shouldBlockSuggestionCalc: boolean) => { - shouldBlockCalc.current = shouldBlockSuggestionCalc; - }, - [shouldBlockCalc], - ); + const setShouldBlockSuggestionCalc = (shouldBlockSuggestionCalc: boolean) => { + shouldBlockCalc.current = shouldBlockSuggestionCalc; + }; - const getSuggestions = useCallback(() => suggestionValues.suggestedEmojis, [suggestionValues.suggestedEmojis]); - - const getIsSuggestionsMenuVisible = useCallback(() => isEmojiSuggestionsMenuVisible, [isEmojiSuggestionsMenuVisible]); - - useImperativeHandle( - ref, - () => ({ - resetSuggestions, - triggerHotkeyActions, - setShouldBlockSuggestionCalc, - updateShouldShowSuggestionMenuToFalse, - getSuggestions, - getIsSuggestionsMenuVisible, - }), - [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions, getIsSuggestionsMenuVisible], - ); + const getSuggestions = () => suggestionValues.suggestedEmojis; + + const getIsSuggestionsMenuVisible = () => isEmojiSuggestionsMenuVisible; + + useImperativeHandle(ref, () => ({ + resetSuggestions, + triggerHotkeyActions, + setShouldBlockSuggestionCalc, + updateShouldShowSuggestionMenuToFalse, + getSuggestions, + getIsSuggestionsMenuVisible, + })); if (!isEmojiSuggestionsMenuVisible) { return null; diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx index e643583941b2b..0dcc15d76bc29 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; import lodashMapValues from 'lodash/mapValues'; import lodashSortBy from 'lodash/sortBy'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import type {Mention} from '@components/MentionSuggestions'; import MentionSuggestions from '@components/MentionSuggestions'; @@ -56,29 +56,27 @@ type SuggestionPersonalDetailsList = Record< | null >; -function SuggestionMention({ - value, - selection, - setSelection, - updateComment, - isAutoSuggestionPickerLarge, - measureParentContainerAndReportCursor, - isComposerFocused, - isGroupPolicyReport, - policyID, - ref, -}: SuggestionProps) { +function SuggestionMention({value, selection, setSelection, updateComment, isAutoSuggestionPickerLarge, measureParentContainerAndReportCursor, isComposerFocused, ref}: SuggestionProps) { const personalDetails = usePersonalDetails(); const {translate, formatPhoneNumber, localeCompare} = useLocalize(); const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); const suggestionValuesRef = useRef(suggestionValues); - const policy = usePolicy(policyID); + // eslint-disable-next-line react-hooks/refs -- intentional sync-ref pattern: keeps ref up to date without effect overhead suggestionValuesRef.current = suggestionValues; + const {currentReportID} = useCurrentReportIDState(); + const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`); + + const policyID = currentReport?.policyID; + const isGroupPolicyReport = !!policyID && policyID !== CONST.POLICY.ID_FAKE; + + const policy = usePolicy(policyID); + // Filter reports to only include those that can be mentioned within the current policy + // useCallback is required by the rulesdir/no-inline-useOnyx-selector ESLint rule const mentionableReportsSelector = useCallback( - (reports: OnyxCollection) => { - return Object.keys(reports ?? {}).reduce( + (reports: OnyxCollection) => + Object.keys(reports ?? {}).reduce( (acc, reportID) => { const report = reports?.[reportID]; if (report && canReportBeMentionedWithinPolicy(report, policyID)) { @@ -87,8 +85,7 @@ function SuggestionMention({ return acc; }, {} as Record, - ); - }, + ), [policyID], ); @@ -100,36 +97,29 @@ function SuggestionMention({ const expensifyIcons = useMemoizedLazyExpensifyIcons(['Megaphone', 'FallbackAvatar']); - const {currentReportID} = useCurrentReportIDState(); - const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`); - // Smaller weight means higher order in suggestion list - const getPersonalDetailsWeight = useCallback( - (detail: PersonalDetails, policyEmployeeAccountIDs: number[]): number => { - if (isReportParticipant(detail.accountID, currentReport)) { - return 0; - } - if (policyEmployeeAccountIDs.includes(detail.accountID)) { - return 1; - } - return 2; - }, - [currentReport], - ); - const weightedPersonalDetails: PersonalDetailsList | SuggestionPersonalDetailsList | undefined = useMemo(() => { - const policyEmployeeAccountIDs = getPolicyEmployeeAccountIDs(policy, currentUserPersonalDetails.accountID); - if (!isGroupChat(currentReport) && !doesReportBelongToWorkspace(currentReport, policyEmployeeAccountIDs, policyID, conciergeReportID)) { - return personalDetails; + function getPersonalDetailsWeight(detail: PersonalDetails, policyEmployeeAccountIDs: number[]): number { + if (isReportParticipant(detail.accountID, currentReport)) { + return 0; + } + if (policyEmployeeAccountIDs.includes(detail.accountID)) { + return 1; } - return lodashMapValues(personalDetails, (detail) => - detail - ? { - ...detail, - weight: getPersonalDetailsWeight(detail, policyEmployeeAccountIDs), - } - : null, - ); - }, [policyID, policy, currentReport, personalDetails, getPersonalDetailsWeight, currentUserPersonalDetails.accountID, conciergeReportID]); + return 2; + } + + const policyEmployeeAccountIDs = getPolicyEmployeeAccountIDs(policy, currentUserPersonalDetails.accountID); + const weightedPersonalDetails: PersonalDetailsList | SuggestionPersonalDetailsList | undefined = + !isGroupChat(currentReport) && !doesReportBelongToWorkspace(currentReport, policyEmployeeAccountIDs, policyID, conciergeReportID) + ? personalDetails + : lodashMapValues(personalDetails, (detail) => + detail + ? { + ...detail, + weight: getPersonalDetailsWeight(detail, policyEmployeeAccountIDs), + } + : null, + ); const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({ isActive: isMentionSuggestionsMenuVisible, @@ -142,8 +132,10 @@ function SuggestionMention({ // Used to detect if the selection has changed since the last suggestion insertion // If so, we reset the suggestionInsertionIndexRef + // eslint-disable-next-line react-hooks/refs -- reading ref during render to detect cursor movement; write clears a transient flag const hasSelectionChanged = !(selection.end === selection.start && selection.start === suggestionInsertionIndexRef.current); if (hasSelectionChanged) { + // eslint-disable-next-line react-hooks/refs -- clearing transient insertion-index flag during render; harmless side-effect suggestionInsertionIndexRef.current = null; } @@ -155,42 +147,33 @@ function SuggestionMention({ * * The function is debounced to not perform requests on every keystroke. */ - const debouncedSearchInServer = useDebounce( - useCallback(() => { - const foundSuggestionsCount = suggestionValues.suggestedMentions.length; - if (suggestionValues.prefixType === '#' && foundSuggestionsCount < 5 && isGroupPolicyReport) { - searchInServer(suggestionValues.mentionPrefix, policyID); - } - }, [suggestionValues.suggestedMentions.length, suggestionValues.prefixType, suggestionValues.mentionPrefix, policyID, isGroupPolicyReport]), - CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME, - ); + const debouncedSearchInServer = useDebounce(() => { + const foundSuggestionsCount = suggestionValues.suggestedMentions.length; + if (suggestionValues.prefixType === '#' && foundSuggestionsCount < 5 && isGroupPolicyReport) { + searchInServer(suggestionValues.mentionPrefix, policyID); + } + }, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); - const formatLoginPrivateDomain = useCallback( - (displayText = '', userLogin = '') => { - if (userLogin !== displayText) { - return displayText; - } - // If the emails are not in the same private domain, we also return the displayText - if (!areEmailsFromSamePrivateDomain(displayText, currentUserPersonalDetails.login ?? '')) { - return Str.removeSMSDomain(displayText); - } + function formatLoginPrivateDomain(displayText = '', userLogin = '') { + if (userLogin !== displayText) { + return displayText; + } + // If the emails are not in the same private domain, we also return the displayText + if (!areEmailsFromSamePrivateDomain(displayText, currentUserPersonalDetails.login ?? '')) { + return Str.removeSMSDomain(displayText); + } - // Otherwise, the emails must be of the same private domain, so we should remove the domain part - return displayText.split('@').at(0); - }, - [currentUserPersonalDetails.login], - ); + // Otherwise, the emails must be of the same private domain, so we should remove the domain part + return displayText.split('@').at(0); + } - const getMentionCode = useCallback( - (mention: Mention, mentionType: string): string => { - if (mentionType === '#') { - // room mention case - return mention.handle ?? ''; - } - return mention.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${formatLoginPrivateDomain(mention.handle, mention.handle)}`; - }, - [formatLoginPrivateDomain], - ); + function getMentionCode(mention: Mention, mentionType: string): string { + if (mentionType === '#') { + // room mention case + return mention.handle ?? ''; + } + return mention.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${formatLoginPrivateDomain(mention.handle, mention.handle)}`; + } function getOriginalMentionText(inputValue: string, atSignIndex: number, whiteSpacesLength = 0) { const rest = inputValue.slice(atSignIndex); @@ -212,271 +195,248 @@ function SuggestionMention({ /** * Replace the code of mention and update selection */ - const insertSelectedMention = useCallback( - (highlightedMentionIndexInner: number) => { - const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); - const mentionObject = suggestionValues.suggestedMentions.at(highlightedMentionIndexInner); - if (!mentionObject || highlightedMentionIndexInner === -1) { - return; - } + function insertSelectedMention(highlightedMentionIndexInner: number) { + const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); + const mentionObject = suggestionValues.suggestedMentions.at(highlightedMentionIndexInner); + if (!mentionObject || highlightedMentionIndexInner === -1) { + return; + } - const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType); - const originalMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix)); + const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType); + const originalMention = getOriginalMentionText(value, suggestionValues.atSignIndex, StringUtils.countWhiteSpaces(suggestionValues.mentionPrefix)); - // We split trailing dot from the mention token so selecting `@a.` can become `@adam.` - // (preserve sentence punctuation) instead of consuming the `.` into the replacement. - let trailingDot = ''; - let mentionToReplace = originalMention; - if (suggestionValues.prefixType === '@' && suggestionValues.mentionPrefix.endsWith('.')) { - trailingDot = originalMention.match(CONST.REGEX.TRAILING_DOTS)?.[0] ?? ''; - mentionToReplace = originalMention.slice(0, originalMention.length - trailingDot.length); - } + // We split trailing dot from the mention token so selecting `@a.` can become `@adam.` + // (preserve sentence punctuation) instead of consuming the `.` into the replacement. + let trailingDot = ''; + let mentionToReplace = originalMention; + if (suggestionValues.prefixType === '@' && suggestionValues.mentionPrefix.endsWith('.')) { + trailingDot = originalMention.match(CONST.REGEX.TRAILING_DOTS)?.[0] ?? ''; + mentionToReplace = originalMention.slice(0, originalMention.length - trailingDot.length); + } - // Append a preserved trailing dot only when it is sentence punctuation, not part of the selected mention match. - const dotToAppend = - trailingDot && ![mentionObject.text, mentionObject.alternateText].some((mentionText) => mentionText.toLowerCase().includes(suggestionValues.mentionPrefix.toLowerCase())) - ? trailingDot - : ''; + // Append a preserved trailing dot only when it is sentence punctuation, not part of the selected mention match. + const dotToAppend = + trailingDot && ![mentionObject.text, mentionObject.alternateText].some((mentionText) => mentionText.toLowerCase().includes(suggestionValues.mentionPrefix.toLowerCase())) + ? trailingDot + : ''; - const commentAfterMention = value.slice( - suggestionValues.atSignIndex + Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length), - ); + const commentAfterMention = value.slice(suggestionValues.atSignIndex + Math.max(mentionToReplace.length, suggestionValues.mentionPrefix.length + suggestionValues.prefixType.length)); - const trimmedCommentAfterMention = trimLeadingSpace(commentAfterMention); - const spacer = !trimmedCommentAfterMention || !CONST.REGEX.STARTS_WITH_PUNCTUATION.test(trimmedCommentAfterMention) ? ' ' : ''; + const trimmedCommentAfterMention = trimLeadingSpace(commentAfterMention); + const spacer = !trimmedCommentAfterMention || !CONST.REGEX.STARTS_WITH_PUNCTUATION.test(trimmedCommentAfterMention) ? ' ' : ''; - updateComment(`${commentBeforeAtSign}${mentionCode}${dotToAppend}${spacer}${trimmedCommentAfterMention}`, true); - const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + dotToAppend.length + spacer.length; - setSelection({ - start: selectionPosition, - end: selectionPosition, - }); - suggestionInsertionIndexRef.current = selectionPosition; - setSuggestionValues((prevState) => ({ - ...prevState, - suggestedMentions: [], - shouldShowSuggestionMenu: false, - })); - }, - [value, suggestionValues.atSignIndex, suggestionValues.suggestedMentions, suggestionValues.prefixType, getMentionCode, updateComment, setSelection, suggestionValues.mentionPrefix], - ); + updateComment(`${commentBeforeAtSign}${mentionCode}${dotToAppend}${spacer}${trimmedCommentAfterMention}`, true); + const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + dotToAppend.length + spacer.length; + setSelection({ + start: selectionPosition, + end: selectionPosition, + }); + suggestionInsertionIndexRef.current = selectionPosition; + setSuggestionValues((prevState) => ({ + ...prevState, + suggestedMentions: [], + shouldShowSuggestionMenu: false, + })); + } /** * Clean data related to suggestions */ - const resetSuggestions = useCallback(() => { + function resetSuggestions() { setSuggestionValues(defaultSuggestionsValues); - }, []); + } /** * Listens for keyboard shortcuts and applies the action */ - const triggerHotkeyActions = useCallback( - (event: KeyboardEvent) => { - const suggestionsExist = suggestionValues.suggestedMentions.length > 0; - - if (((!event.shiftKey && event.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { - event.preventDefault(); - if (suggestionValues.suggestedMentions.length > 0) { - insertSelectedMention(highlightedMentionIndex); - return true; - } + function triggerHotkeyActions(event: KeyboardEvent) { + const suggestionsExist = suggestionValues.suggestedMentions.length > 0; + + if (((!event.shiftKey && event.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { + event.preventDefault(); + if (suggestionValues.suggestedMentions.length > 0) { + insertSelectedMention(highlightedMentionIndex); + return true; + } + } + + if (event.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + event.preventDefault(); + + if (suggestionsExist) { + resetSuggestions(); } - if (event.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { - event.preventDefault(); + return true; + } + } - if (suggestionsExist) { - resetSuggestions(); - } + function getUserMentionOptions(personalDetailsParam: PersonalDetailsList | SuggestionPersonalDetailsList | undefined, searchValue = ''): Mention[] { + const suggestions: Mention[] = []; + + if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { + suggestions.push({ + text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, + alternateText: translate('mentionSuggestions.hereAlternateText'), + icons: [ + { + source: expensifyIcons.Megaphone, + type: CONST.ICON_TYPE_AVATAR, + }, + ], + }); + } - return true; + // Create a set to track logins that have already been seen + const seenLogins = new Set(); + const filteredPersonalDetails = Object.values(personalDetailsParam ?? {}).filter((detail) => { + // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned + if (!detail?.login || detail.isOptimisticPersonalDetail) { + return false; + } + // We don't want to mention system emails like notifications@expensify.com + if (CONST.RESTRICTED_EMAILS.includes(detail.login) || CONST.RESTRICTED_ACCOUNT_IDS.includes(detail.accountID)) { + return false; + } + const displayName = getDisplayNameOrDefault(detail); + const displayText = displayName === formatPhoneNumber(detail.login) ? displayName : `${displayName} ${detail.login}`; + if (searchValue && !displayText.toLowerCase().includes(searchValue.toLowerCase())) { + return false; } - }, - [highlightedMentionIndex, insertSelectedMention, resetSuggestions, suggestionValues.suggestedMentions.length], - ); - const getUserMentionOptions = useCallback( - (personalDetailsParam: PersonalDetailsList | SuggestionPersonalDetailsList | undefined, searchValue = ''): Mention[] => { - const suggestions: Mention[] = []; - - if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { - suggestions.push({ - text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, - alternateText: translate('mentionSuggestions.hereAlternateText'), - icons: [ - { - source: expensifyIcons.Megaphone, - type: CONST.ICON_TYPE_AVATAR, - }, - ], - }); + // Given the mention is inserted by user, we don't want to show the mention options unless the + // selection index changes. In that case, suggestionInsertionIndexRef.current will be null. + // See https://github.com/Expensify/App/issues/38358 for more context + if (suggestionInsertionIndexRef.current) { + return false; } - // Create a set to track logins that have already been seen - const seenLogins = new Set(); - const filteredPersonalDetails = Object.values(personalDetailsParam ?? {}).filter((detail) => { - // If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned - if (!detail?.login || detail.isOptimisticPersonalDetail) { - return false; - } - // We don't want to mention system emails like notifications@expensify.com - if (CONST.RESTRICTED_EMAILS.includes(detail.login) || CONST.RESTRICTED_ACCOUNT_IDS.includes(detail.accountID)) { - return false; - } - const displayName = getDisplayNameOrDefault(detail); - const displayText = displayName === formatPhoneNumber(detail.login) ? displayName : `${displayName} ${detail.login}`; - if (searchValue && !displayText.toLowerCase().includes(searchValue.toLowerCase())) { - return false; - } - - // Given the mention is inserted by user, we don't want to show the mention options unless the - // selection index changes. In that case, suggestionInsertionIndexRef.current will be null. - // See https://github.com/Expensify/App/issues/38358 for more context - if (suggestionInsertionIndexRef.current) { - return false; - } - - // on staging server, in specific cases (see issue) BE returns duplicated personalDetails - // entries with the same `login` which we need to filter out - if (seenLogins.has(detail.login)) { - return false; - } - seenLogins.add(detail.login); - return true; - }) as Array; - - // At this point we are sure that the details are not null, since empty user details have been filtered in the previous step - const sortedPersonalDetails = getSortedPersonalDetails(filteredPersonalDetails, localeCompare); - - for (const detail of sortedPersonalDetails.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length)) { - suggestions.push({ - text: `${formatLoginPrivateDomain(getDisplayNameOrDefault(detail), detail?.login)}`, - alternateText: `@${formatLoginPrivateDomain(detail?.login, detail?.login)}`, - handle: detail?.login, - icons: [ - { - name: detail?.login, - source: detail?.avatar ?? expensifyIcons.FallbackAvatar, - type: CONST.ICON_TYPE_AVATAR, - fallbackIcon: detail?.fallbackIcon, - id: detail?.accountID, - }, - ], - }); + // on staging server, in specific cases (see issue) BE returns duplicated personalDetails + // entries with the same `login` which we need to filter out + if (seenLogins.has(detail.login)) { + return false; } + seenLogins.add(detail.login); + return true; + }) as Array; + + // At this point we are sure that the details are not null, since empty user details have been filtered in the previous step + const sortedPersonalDetails = getSortedPersonalDetails(filteredPersonalDetails, localeCompare); + + for (const detail of sortedPersonalDetails.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length)) { + suggestions.push({ + text: `${formatLoginPrivateDomain(getDisplayNameOrDefault(detail), detail?.login)}`, + alternateText: `@${formatLoginPrivateDomain(detail?.login, detail?.login)}`, + handle: detail?.login, + icons: [ + { + name: detail?.login, + source: detail?.avatar ?? expensifyIcons.FallbackAvatar, + type: CONST.ICON_TYPE_AVATAR, + fallbackIcon: detail?.fallbackIcon, + id: detail?.accountID, + }, + ], + }); + } - return suggestions; - }, - [localeCompare, translate, expensifyIcons.Megaphone, expensifyIcons.FallbackAvatar, formatPhoneNumber, formatLoginPrivateDomain], - ); + return suggestions; + } - const getRoomMentionOptions = useCallback( - (searchTerm: string): Mention[] => { - const filteredRoomMentions: Mention[] = []; - for (const report of Object.values(mentionableReports ?? {})) { - if (report?.reportName?.toLowerCase().includes(searchTerm.toLowerCase())) { - filteredRoomMentions.push({ - text: report.reportName, - handle: report.reportName, - alternateText: report.reportName, - }); - } + function getRoomMentionOptions(searchTerm: string): Mention[] { + const filteredRoomMentions: Mention[] = []; + for (const report of Object.values(mentionableReports ?? {})) { + if (report?.reportName?.toLowerCase().includes(searchTerm.toLowerCase())) { + filteredRoomMentions.push({ + text: report.reportName, + handle: report.reportName, + alternateText: report.reportName, + }); } + } - return lodashSortBy(filteredRoomMentions, 'handle').slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS); - }, - [mentionableReports], - ); + return lodashSortBy(filteredRoomMentions, 'handle').slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS); + } - const calculateMentionSuggestion = useCallback( - (newValue: string, selectionStart?: number, selectionEnd?: number) => { - if (selectionEnd !== selectionStart || !selectionEnd || shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) { - shouldBlockCalc.current = false; - resetSuggestions(); - return; - } + function calculateMentionSuggestion(newValue: string, selectionStart?: number, selectionEnd?: number) { + if (selectionEnd !== selectionStart || !selectionEnd || shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) { + shouldBlockCalc.current = false; + resetSuggestions(); + return; + } - const afterLastBreakLineIndex = newValue.lastIndexOf('\n', selectionEnd - 1) + 1; - const leftString = newValue.substring(afterLastBreakLineIndex, selectionEnd); - const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI); - const lastWord: string = words.at(-1) ?? ''; - const secondToLastWord = words.at(-3); - - let atSignIndex: number | undefined; - let suggestionWord = ''; - let prefix: string; - let prefixType = ''; - - // Detect if the last two words contain a mention (two words are needed to detect a mention with a space in it) - if (lastWord.startsWith('@') || lastWord.startsWith('#')) { - atSignIndex = leftString.lastIndexOf(lastWord) + afterLastBreakLineIndex; - suggestionWord = lastWord; - - prefix = suggestionWord.substring(1); - prefixType = suggestionWord.substring(0, 1); - } else if (secondToLastWord && secondToLastWord.startsWith('@') && secondToLastWord.length > 1) { - atSignIndex = leftString.lastIndexOf(secondToLastWord) + afterLastBreakLineIndex; - suggestionWord = `${secondToLastWord} ${lastWord}`; - - prefix = suggestionWord.substring(1); - prefixType = suggestionWord.substring(0, 1); - } else { - prefix = lastWord.substring(1); - } + const afterLastBreakLineIndex = newValue.lastIndexOf('\n', selectionEnd - 1) + 1; + const leftString = newValue.substring(afterLastBreakLineIndex, selectionEnd); + const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI); + const lastWord: string = words.at(-1) ?? ''; + const secondToLastWord = words.at(-3); + + let atSignIndex: number | undefined; + let suggestionWord = ''; + let prefix: string; + let prefixType = ''; + + // Detect if the last two words contain a mention (two words are needed to detect a mention with a space in it) + if (lastWord.startsWith('@') || lastWord.startsWith('#')) { + atSignIndex = leftString.lastIndexOf(lastWord) + afterLastBreakLineIndex; + suggestionWord = lastWord; + + prefix = suggestionWord.substring(1); + prefixType = suggestionWord.substring(0, 1); + } else if (secondToLastWord && secondToLastWord.startsWith('@') && secondToLastWord.length > 1) { + atSignIndex = leftString.lastIndexOf(secondToLastWord) + afterLastBreakLineIndex; + suggestionWord = `${secondToLastWord} ${lastWord}`; + + prefix = suggestionWord.substring(1); + prefixType = suggestionWord.substring(0, 1); + } else { + prefix = lastWord.substring(1); + } - // Treat a trailing dot as punctuation so short mentions like "@a." still match "@a". - const hasTrailingDot = prefixType === '@' && prefix.length > 1 && prefix.endsWith('.'); - const normalizedPrefix = hasTrailingDot ? prefix.slice(0, -1) : prefix; - // Keep the raw prefix for highlight so dots are preserved in the UI. - const mentionPrefix = prefix; - - const nextState: Partial = { - suggestedMentions: [], - atSignIndex, - mentionPrefix, - prefixType, - }; - - if (isMentionCode(suggestionWord) && prefixType === '@') { - const suggestions = getUserMentionOptions(weightedPersonalDetails, normalizedPrefix); - nextState.suggestedMentions = suggestions; - nextState.shouldShowSuggestionMenu = !!suggestions.length; - } + // Treat a trailing dot as punctuation so short mentions like "@a." still match "@a". + const hasTrailingDot = prefixType === '@' && prefix.length > 1 && prefix.endsWith('.'); + const normalizedPrefix = hasTrailingDot ? prefix.slice(0, -1) : prefix; + // Keep the raw prefix for highlight so dots are preserved in the UI. + const mentionPrefix = prefix; + + const nextState: Partial = { + suggestedMentions: [], + atSignIndex, + mentionPrefix, + prefixType, + }; + + if (isMentionCode(suggestionWord) && prefixType === '@') { + const suggestions = getUserMentionOptions(weightedPersonalDetails, normalizedPrefix); + nextState.suggestedMentions = suggestions; + nextState.shouldShowSuggestionMenu = !!suggestions.length; + } - const shouldDisplayRoomMentionsSuggestions = isGroupPolicyReport && (isValidRoomName(suggestionWord.toLowerCase()) || normalizedPrefix === ''); - if (prefixType === '#' && shouldDisplayRoomMentionsSuggestions) { - // Filter reports by room name and current policy - nextState.suggestedMentions = getRoomMentionOptions(normalizedPrefix); + const shouldDisplayRoomMentionsSuggestions = isGroupPolicyReport && (isValidRoomName(suggestionWord.toLowerCase()) || normalizedPrefix === ''); + if (prefixType === '#' && shouldDisplayRoomMentionsSuggestions) { + // Filter reports by room name and current policy + nextState.suggestedMentions = getRoomMentionOptions(normalizedPrefix); - // Even if there are no reports, we should show the suggestion menu - to perform live search - nextState.shouldShowSuggestionMenu = true; - } + // Even if there are no reports, we should show the suggestion menu - to perform live search + nextState.shouldShowSuggestionMenu = true; + } - // Early return if there is no update - const currentState = suggestionValuesRef.current; - if (currentState.suggestedMentions.length === 0 && nextState.suggestedMentions?.length === 0) { - return; - } + // Early return if there is no update + const currentState = suggestionValuesRef.current; + if (currentState.suggestedMentions.length === 0 && nextState.suggestedMentions?.length === 0) { + return; + } - setSuggestionValues((prevState) => ({ - ...prevState, - ...nextState, - })); - setHighlightedMentionIndex(0); - }, - [isComposerFocused, isGroupPolicyReport, setHighlightedMentionIndex, resetSuggestions, getUserMentionOptions, weightedPersonalDetails, getRoomMentionOptions], - ); + setSuggestionValues((prevState) => ({ + ...prevState, + ...nextState, + })); + setHighlightedMentionIndex(0); + } - const debouncedCalculateMentionSuggestion = useDebounce( - useCallback( - (newValue: string, selectionStart?: number, selectionEnd?: number) => { - calculateMentionSuggestion(newValue, selectionStart, selectionEnd); - }, - [calculateMentionSuggestion], - ), - CONST.TIMING.SUGGESTION_DEBOUNCE_TIME, - ); + const debouncedCalculateMentionSuggestion = useDebounce((newValue: string, selectionStart?: number, selectionEnd?: number) => { + calculateMentionSuggestion(newValue, selectionStart, selectionEnd); + }, CONST.TIMING.SUGGESTION_DEBOUNCE_TIME); useEffect(() => { debouncedCalculateMentionSuggestion(value, selection.start, selection.end); @@ -486,37 +446,35 @@ function SuggestionMention({ debouncedSearchInServer(); }, [suggestionValues.suggestedMentions.length, suggestionValues.prefixType, policyID, value, debouncedSearchInServer]); - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + function updateShouldShowSuggestionMenuToFalse() { setSuggestionValues((prevState) => { if (prevState.shouldShowSuggestionMenu) { return {...prevState, shouldShowSuggestionMenu: false}; } return prevState; }); - }, []); + } - const setShouldBlockSuggestionCalc = useCallback( - (shouldBlockSuggestionCalc: boolean) => { - shouldBlockCalc.current = shouldBlockSuggestionCalc; - }, - [shouldBlockCalc], - ); + function setShouldBlockSuggestionCalc(shouldBlockSuggestionCalc: boolean) { + shouldBlockCalc.current = shouldBlockSuggestionCalc; + } - const getSuggestions = useCallback(() => suggestionValues.suggestedMentions, [suggestionValues.suggestedMentions]); - const getIsSuggestionsMenuVisible = useCallback(() => isMentionSuggestionsMenuVisible, [isMentionSuggestionsMenuVisible]); - - useImperativeHandle( - ref, - () => ({ - resetSuggestions, - triggerHotkeyActions, - setShouldBlockSuggestionCalc, - updateShouldShowSuggestionMenuToFalse, - getSuggestions, - getIsSuggestionsMenuVisible, - }), - [resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions, getIsSuggestionsMenuVisible], - ); + function getSuggestions() { + return suggestionValues.suggestedMentions; + } + + function getIsSuggestionsMenuVisible() { + return isMentionSuggestionsMenuVisible; + } + + useImperativeHandle(ref, () => ({ + resetSuggestions, + triggerHotkeyActions, + setShouldBlockSuggestionCalc, + updateShouldShowSuggestionMenuToFalse, + getSuggestions, + getIsSuggestionsMenuVisible, + })); if (!isMentionSuggestionsMenuVisible) { return null; @@ -527,9 +485,11 @@ function SuggestionMention({ highlightedMentionIndex={highlightedMentionIndex} mentions={suggestionValues.suggestedMentions} prefix={suggestionValues.mentionPrefix} + // eslint-disable-next-line react/jsx-no-bind -- React Compiler memoizes these automatically onSelect={insertSelectedMention} isMentionPickerLarge={!!isAutoSuggestionPickerLarge} measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} + // eslint-disable-next-line react/jsx-no-bind -- React Compiler memoizes these automatically resetSuggestions={resetSuggestions} /> ); diff --git a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx index ab3342f75d16a..af293549b7f2c 100644 --- a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx @@ -1,12 +1,12 @@ import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useRef} from 'react'; +import React, {useEffect, useImperativeHandle, useRef} from 'react'; import type {TextInputSelectionChangeEvent} from 'react-native'; import {View} from 'react-native'; import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; import type {TextSelection} from '@components/Composer/types'; import {useDragAndDropState} from '@components/DragAndDrop/Provider'; import usePrevious from '@hooks/usePrevious'; -import type {SuggestionsRef} from './ReportActionCompose'; +import type {SuggestionsRef} from './ComposerContext'; import SuggestionEmoji from './SuggestionEmoji'; import SuggestionMention from './SuggestionMention'; @@ -38,12 +38,6 @@ type SuggestionProps = { /** The height of the composer */ composerHeight?: number; - /** If current composer is connected with report from group policy */ - isGroupPolicyReport: boolean; - - /** The policyID of the report connected to current composer */ - policyID?: string; - /** Reference to the outer element */ ref?: ForwardedRef; }; @@ -62,8 +56,6 @@ function Suggestions({ measureParentContainerAndReportCursor, isAutoSuggestionPickerLarge = true, isComposerFocused, - isGroupPolicyReport, - policyID, ref, }: SuggestionProps) { const suggestionEmojiRef = useRef(null); @@ -71,7 +63,7 @@ function Suggestions({ const {isDraggingOver} = useDragAndDropState(); const prevIsDraggingOver = usePrevious(isDraggingOver); - const getSuggestions = useCallback(() => { + function getSuggestions() { if (suggestionEmojiRef.current?.getSuggestions) { const emojiSuggestions = suggestionEmojiRef.current.getSuggestions(); if (emojiSuggestions.length > 0) { @@ -87,59 +79,56 @@ function Suggestions({ } return []; - }, []); + } /** * Clean data related to EmojiSuggestions */ - const resetSuggestions = useCallback(() => { + function resetSuggestions() { suggestionEmojiRef.current?.resetSuggestions(); suggestionMentionRef.current?.resetSuggestions(); - }, []); + } /** * Listens for keyboard shortcuts and applies the action */ - const triggerHotkeyActions = useCallback((e: KeyboardEvent) => { + function triggerHotkeyActions(e: KeyboardEvent) { const emojiHandler = suggestionEmojiRef.current?.triggerHotkeyActions(e); const mentionHandler = suggestionMentionRef.current?.triggerHotkeyActions(e); return emojiHandler ?? mentionHandler; - }, []); + } - const onSelectionChange = useCallback((e: TextInputSelectionChangeEvent) => { + function onSelectionChange(e: TextInputSelectionChangeEvent) { const emojiHandler = suggestionEmojiRef.current?.onSelectionChange?.(e); suggestionMentionRef.current?.onSelectionChange?.(e); return emojiHandler; - }, []); + } - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + function updateShouldShowSuggestionMenuToFalse() { suggestionEmojiRef.current?.updateShouldShowSuggestionMenuToFalse(); suggestionMentionRef.current?.updateShouldShowSuggestionMenuToFalse(); - }, []); + } - const setShouldBlockSuggestionCalc = useCallback((shouldBlock: boolean) => { + function setShouldBlockSuggestionCalc(shouldBlock: boolean) { suggestionEmojiRef.current?.setShouldBlockSuggestionCalc(shouldBlock); suggestionMentionRef.current?.setShouldBlockSuggestionCalc(shouldBlock); - }, []); - const getIsSuggestionsMenuVisible = useCallback((): boolean => { + } + + function getIsSuggestionsMenuVisible(): boolean { const isEmojiVisible = suggestionEmojiRef.current?.getIsSuggestionsMenuVisible() ?? false; const isSuggestionVisible = suggestionMentionRef.current?.getIsSuggestionsMenuVisible() ?? false; return isEmojiVisible || isSuggestionVisible; - }, []); - - useImperativeHandle( - ref, - () => ({ - resetSuggestions, - onSelectionChange, - triggerHotkeyActions, - updateShouldShowSuggestionMenuToFalse, - setShouldBlockSuggestionCalc, - getSuggestions, - getIsSuggestionsMenuVisible, - }), - [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions, getIsSuggestionsMenuVisible], - ); + } + + useImperativeHandle(ref, () => ({ + resetSuggestions, + onSelectionChange, + triggerHotkeyActions, + updateShouldShowSuggestionMenuToFalse, + setShouldBlockSuggestionCalc, + getSuggestions, + getIsSuggestionsMenuVisible, + })); useEffect(() => { if (!(!prevIsDraggingOver && isDraggingOver)) { @@ -156,8 +145,6 @@ function Suggestions({ isAutoSuggestionPickerLarge, measureParentContainerAndReportCursor, isComposerFocused, - isGroupPolicyReport, - policyID, }; return ( diff --git a/src/pages/inbox/report/ReportActionCompose/useAttachmentPicker.ts b/src/pages/inbox/report/ReportActionCompose/useAttachmentPicker.ts new file mode 100644 index 0000000000000..24fa6f7a6f735 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useAttachmentPicker.ts @@ -0,0 +1,100 @@ +import {useContext, useState} from 'react'; +import useFilesValidation from '@hooks/useFilesValidation'; +import useLocalize from '@hooks/useLocalize'; +import ComposerFocusManager from '@libs/ComposerFocusManager'; +import {cleanFileObject, cleanFileObjectName, getFilesFromClipboardEvent} from '@libs/fileDownload/FileUtils'; +import Navigation from '@navigation/Navigation'; +import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {FileObject} from '@src/types/utils/Attachment'; +import {useComposerActions, useComposerMeta, useComposerSendState} from './ComposerContext'; + +function useAttachmentPicker(reportID: string) { + const {translate} = useLocalize(); + const {exceededMaxLength} = useComposerSendState(); + const {clearComposer} = useComposerActions(); + const {attachmentFileRef, suggestionsRef} = useComposerMeta(); + const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); + + const reportAttachmentsContext = useContext(AttachmentModalContext); + + const addAttachment = (file: FileObject | FileObject[]) => { + attachmentFileRef.current = file; + clearComposer(); + }; + + const onAttachmentPreviewClose = () => { + if (suggestionsRef.current) { + suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); + } + setIsAttachmentPreviewActive(false); + ComposerFocusManager.setReadyToFocus(); + }; + + const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { + if (files.length === 0) { + return; + } + + reportAttachmentsContext.setCurrentAttachment({ + reportID, + file: files, + dataTransferItems, + headerTitle: translate('reportActionCompose.sendAttachment'), + onConfirm: addAttachment, + onShow: () => setIsAttachmentPreviewActive(true), + onClose: onAttachmentPreviewClose, + shouldDisableSendButton: !!exceededMaxLength, + }); + Navigation.navigate(ROUTES.REPORT_ADD_ATTACHMENT.getRoute(reportID)); + }; + + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); + + const pickAttachments = ({dragEvent, files}: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => { + if (isAttachmentPreviewActive) { + return; + } + + let extractedFiles: FileObject[] = []; + + if (files) { + extractedFiles = Array.isArray(files) ? files : [files]; + } else { + if (!dragEvent) { + return; + } + extractedFiles = getFilesFromClipboardEvent(dragEvent); + } + + const dataTransferItems = Array.from(dragEvent?.dataTransfer?.items ?? []); + if (extractedFiles.length === 0) { + return; + } + + const validIndices: number[] = []; + const fileObjects = extractedFiles + .map((item, index) => { + const fileObject = cleanFileObject(item); + const cleanedFileObject = cleanFileObjectName(fileObject); + if (cleanedFileObject !== null) { + validIndices.push(index); + } + return cleanedFileObject; + }) + .filter((fileObject) => fileObject !== null); + + if (!fileObjects.length) { + return; + } + + const filteredItems = dataTransferItems && validIndices.length > 0 ? validIndices.map((index) => dataTransferItems.at(index) ?? ({} as DataTransferItem)) : undefined; + + validateFiles(fileObjects, filteredItems, {isValidatingReceipts: false}); + }; + + return {pickAttachments, PDFValidationComponent, ErrorModal}; +} + +export default useAttachmentPicker; diff --git a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts b/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts deleted file mode 100644 index a0215ce1fbbad..0000000000000 --- a/src/pages/inbox/report/ReportActionCompose/useAttachmentUploadValidation.ts +++ /dev/null @@ -1,220 +0,0 @@ -import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import {useCallback, useContext, useMemo, useRef} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import useFilesValidation from '@hooks/useFilesValidation'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import usePersonalPolicy from '@hooks/usePersonalPolicy'; -import {cleanFileObject, cleanFileObjectName, getFilesFromClipboardEvent} from '@libs/fileDownload/FileUtils'; -import {hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil} from '@libs/PolicyUtils'; -import {isSelfDM} from '@libs/ReportUtils'; -import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import Navigation from '@navigation/Navigation'; -import AttachmentModalContext from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; -import {initMoneyRequest, replaceReceipt, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt} from '@userActions/IOU'; -import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; -import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; -import type {FileObject} from '@src/types/utils/Attachment'; - -type AttachmentUploadValidationProps = { - policy: OnyxEntry; - reportID: string; - addAttachment: (file: FileObject | FileObject[]) => void; - onAttachmentPreviewClose: () => void; - exceededMaxLength: boolean | number | null; - shouldAddOrReplaceReceipt: boolean; - transactionID: string | undefined; - report: OnyxEntry; - newParentReport: OnyxEntry; - currentDate: string | undefined; - currentUserPersonalDetails: CurrentUserPersonalDetails; - isAttachmentPreviewActive: boolean; - setIsAttachmentPreviewActive: (isActive: boolean) => void; -}; - -function useAttachmentUploadValidation({ - policy, - reportID, - addAttachment, - onAttachmentPreviewClose, - exceededMaxLength, - shouldAddOrReplaceReceipt, - transactionID, - report, - newParentReport, - currentDate, - currentUserPersonalDetails, - isAttachmentPreviewActive, - setIsAttachmentPreviewActive, -}: AttachmentUploadValidationProps) { - const {translate} = useLocalize(); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); - const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - const personalPolicy = usePersonalPolicy(); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const hasOnlyPersonalPolicies = useMemo(() => hasOnlyPersonalPoliciesUtil(allPolicies), [allPolicies]); - const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); - - const reportAttachmentsContext = useContext(AttachmentModalContext); - const showAttachmentModalScreen = useCallback( - (file: FileObject | FileObject[], dataTransferItems?: DataTransferItem[]) => { - reportAttachmentsContext.setCurrentAttachment({ - reportID, - file, - dataTransferItems, - headerTitle: translate('reportActionCompose.sendAttachment'), - onConfirm: addAttachment, - onShow: () => setIsAttachmentPreviewActive(true), - onClose: onAttachmentPreviewClose, - shouldDisableSendButton: !!exceededMaxLength, - }); - Navigation.navigate(ROUTES.REPORT_ADD_ATTACHMENT.getRoute(reportID)); - }, - [addAttachment, exceededMaxLength, onAttachmentPreviewClose, reportAttachmentsContext, reportID, setIsAttachmentPreviewActive, translate], - ); - - const attachmentUploadType = useRef<'receipt' | 'attachment'>(undefined); - const onFilesValidated = (files: FileObject[], dataTransferItems: DataTransferItem[]) => { - if (files.length === 0) { - return; - } - - if (attachmentUploadType.current === 'attachment') { - showAttachmentModalScreen(files, dataTransferItems); - return; - } - - if (shouldAddOrReplaceReceipt && transactionID) { - const source = URL.createObjectURL(files.at(0) as Blob); - replaceReceipt({transactionID, file: files.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); - return; - } - - const initialTransaction = initMoneyRequest({ - reportID, - personalPolicy, - newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - report, - parentReport: newParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies, - draftTransactionIDs, - }); - - for (const [index, file] of files.entries()) { - const source = URL.createObjectURL(file as Blob); - const newTransaction = - index === 0 - ? (initialTransaction as Partial) - : buildOptimisticTransactionAndCreateDraft({ - initialTransaction: initialTransaction as Partial, - currentUserPersonalDetails, - reportID, - }); - const newTransactionID = newTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; - setMoneyRequestReceipt(newTransactionID, source, file.name ?? '', true, file.type); - setMoneyRequestParticipantsFromReport(newTransactionID, report, currentUserPersonalDetails.accountID); - } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( - CONST.IOU.ACTION.CREATE, - isSelfDM(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT, - CONST.IOU.OPTIMISTIC_TRANSACTION_ID, - reportID, - ), - ); - }; - - const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); - - const validateAttachments = useCallback( - ({dragEvent, files}: {dragEvent?: DragEvent; files?: FileObject | FileObject[]}) => { - if (isAttachmentPreviewActive) { - return; - } - - let extractedFiles: FileObject[] = []; - - if (files) { - extractedFiles = Array.isArray(files) ? files : [files]; - } else { - if (!dragEvent) { - return; - } - - extractedFiles = getFilesFromClipboardEvent(dragEvent); - } - - const dataTransferItems = Array.from(dragEvent?.dataTransfer?.items ?? []); - if (extractedFiles.length === 0) { - return; - } - - const validIndices: number[] = []; - const fileObjects = extractedFiles - .map((item, index) => { - const fileObject = cleanFileObject(item); - const cleanedFileObject = cleanFileObjectName(fileObject); - if (cleanedFileObject !== null) { - validIndices.push(index); - } - return cleanedFileObject; - }) - .filter((fileObject) => fileObject !== null); - - if (!fileObjects.length) { - return; - } - - // Create a filtered items array that matches the fileObjects - const filteredItems = dataTransferItems && validIndices.length > 0 ? validIndices.map((index) => dataTransferItems.at(index) ?? ({} as DataTransferItem)) : undefined; - - attachmentUploadType.current = 'attachment'; - validateFiles(fileObjects, filteredItems, {isValidatingReceipts: false}); - }, - [isAttachmentPreviewActive, validateFiles], - ); - - const onReceiptDropped = useCallback( - (e: DragEvent) => { - if (policy && shouldRestrictUserBillableActions(policy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - - const files = getFilesFromClipboardEvent(e); - const items = Array.from(e.dataTransfer?.items ?? []); - - if (shouldAddOrReplaceReceipt && transactionID) { - const file = files.at(0); - if (!file) { - return; - } - - attachmentUploadType.current = 'receipt'; - validateFiles([file], items); - } - - attachmentUploadType.current = 'receipt'; - validateFiles(files, items, {isValidatingReceipts: true}); - }, - [policy, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, shouldAddOrReplaceReceipt, transactionID, validateFiles, amountOwed], - ); - - return { - validateAttachments, - onReceiptDropped, - PDFValidationComponent, - ErrorModal, - }; -} - -export default useAttachmentUploadValidation; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts new file mode 100644 index 0000000000000..78ca74dba6e36 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useComposerFocus.ts @@ -0,0 +1,63 @@ +import {useRef, useState} from 'react'; +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'; + +const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); + +type UseComposerFocusParams = { + composerRef: RefObject; + suggestionsRef: RefObject; + actionButtonRef: RefObject; + initialFocused: boolean; +}; + +function useComposerFocus({composerRef, suggestionsRef, actionButtonRef, initialFocused}: UseComposerFocusParams) { + const [isFocused, setIsFocused] = useState(initialFocused); + 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(); + } + composerRef.current?.blur(); + }; + + const onItemSelected = () => { + isKeyboardVisibleWhenShowingModalRef.current = false; + }; + + const onTriggerAttachmentPicker = () => { + isNextModalWillOpenRef.current = true; + isKeyboardVisibleWhenShowingModalRef.current = true; + }; + + const onBlur = (event: BlurEvent) => { + const webEvent = event as unknown as FocusEvent; + setIsFocused(false); + if (suggestionsRef.current) { + suggestionsRef.current.resetSuggestions(); + } + if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) { + isKeyboardVisibleWhenShowingModalRef.current = true; + } + }; + + const onFocus = () => { + setIsFocused(true); + }; + + return {isFocused, onBlur, onFocus, focus, 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 new file mode 100644 index 0000000000000..9c35d0fc216ec --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -0,0 +1,171 @@ +import {Str} from 'expensify-common'; +import {useContext} from 'react'; +import type {RefObject} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useAncestors from '@hooks/useAncestors'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsInSidePanel from '@hooks/useIsInSidePanel'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import useShortMentionsList from '@hooks/useShortMentionsList'; +import {addComment} from '@libs/actions/Report'; +import {isEmailPublicDomain} from '@libs/LoginUtils'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {rand64} from '@libs/NumberUtils'; +import {addDomainToShortMention} from '@libs/ParsingUtils'; +import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; +import {startSpan} from '@libs/telemetry/activeSpans'; +import {generateAccountID} from '@libs/UserUtils'; +import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; +import {ActionListContext} from '@pages/inbox/ReportScreenContext'; +import {addAttachmentWithComment} from '@userActions/Report'; +import {createTaskAndNavigate, setNewOptimisticAssignee} from '@userActions/Task'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; + +type UseComposerSubmitParams = { + report: OnyxEntry; + reportID: string; + attachmentFileRef: RefObject; +}; + +function useComposerSubmit({report, reportID, attachmentFileRef}: UseComposerSubmitParams) { + const isInSidePanel = useIsInSidePanel(); + const {kickoffWaitingIndicator} = useAgentZeroStatusActions(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalDetails = usePersonalDetails(); + const {availableLoginsList} = useShortMentionsList(); + const {scrollOffsetRef} = useContext(ActionListContext); + + const {isOffline} = useNetwork(); + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); + const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + const allReportTransactions = useReportTransactionsCollection(reportID); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); + const visibleTransactions = isOffline ? reportTransactions : reportTransactions?.filter((t) => t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); + const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); + const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; + + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${effectiveTransactionThreadReportID ?? reportID}`); + + const reportAncestors = useAncestors(report); + const targetReportAncestors = useAncestors(targetReport); + + const currentUserEmail = currentUserPersonalDetails.email ?? ''; + + const handleCreateTask = (text: string): boolean => { + const match = text.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + if (!match) { + return false; + } + let title = match[3] ? match[3].trim().replaceAll('\n', ' ') : undefined; + if (!title) { + return false; + } + + const mention = match[1] ? match[1].trim() : ''; + const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); + const mentionWithDomain = addDomainToShortMention(mention, availableLoginsList, currentUserPrivateDomain) ?? mention; + const isValidMention = Str.isValidEmail(mentionWithDomain); + + let assignee: OnyxEntry; + let assigneeChatReport; + if (mentionWithDomain) { + if (isValidMention) { + assignee = Object.values(personalDetails ?? {}).find((value) => value?.login === mentionWithDomain) ?? undefined; + if (!Object.keys(assignee ?? {}).length) { + const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { + accountID: generateAccountID(mentionWithDomain), + login: mentionWithDomain, + }); + assignee = optimisticDataForNewAssignee.assignee; + assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; + } + } else { + title = `@${mentionWithDomain} ${title}`; + } + } + createTaskAndNavigate({ + parentReport: report, + title, + description: '', + assigneeEmail: assignee?.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail, + assigneeAccountID: assignee?.accountID, + assigneeChatReport, + policyID: report?.policyID, + isCreatedUsingMarkdown: true, + quickAction, + ancestors: reportAncestors, + }); + return true; + }; + + const submitForm = (newComment: string) => { + const newCommentTrimmed = newComment.trim(); + + kickoffWaitingIndicator(); + + if (attachmentFileRef.current) { + addAttachmentWithComment({ + report: targetReport, + notifyReportID: reportID, + ancestors: targetReportAncestors, + attachments: attachmentFileRef.current, + currentUserAccountID: currentUserPersonalDetails.accountID, + text: newCommentTrimmed, + timezone: currentUserPersonalDetails.timezone, + shouldPlaySound: true, + isInSidePanel, + }); + // eslint-disable-next-line no-param-reassign + attachmentFileRef.current = null; + return; + } + + if (handleCreateTask(newCommentTrimmed)) { + return; + } + + // Pre-generate the reportActionID so we can correlate the Sentry send-message span with the exact message + const optimisticReportActionID = rand64(); + + // The list is inverted, so an offset near 0 means the user is at the bottom (newest messages visible). + const isScrolledToBottom = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + if (isScrolledToBottom) { + startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${optimisticReportActionID}`, { + name: 'send-message', + op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, + [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, + }, + }); + } + addComment({ + report: targetReport, + notifyReportID: reportID, + ancestors: targetReportAncestors, + text: newCommentTrimmed, + timezoneParam: currentUserPersonalDetails.timezone ?? CONST.DEFAULT_TIME_ZONE, + currentUserAccountID: currentUserPersonalDetails.accountID, + shouldPlaySound: true, + isInSidePanel, + reportActionID: optimisticReportActionID, + }); + }; + + return {submitForm}; +} + +export default useComposerSubmit; diff --git a/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts b/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts new file mode 100644 index 0000000000000..268600910e403 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts @@ -0,0 +1,49 @@ +import {useRoute} from '@react-navigation/native'; +import type {OnyxEntry} from 'react-native-onyx'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useParentReportAction from '@hooks/useParentReportAction'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getCombinedReportActions, getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isMoneyRequestAction, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; +import {canEditReportAction} from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {ReportAction} from '@src/types/onyx'; + +/** + * Self-contained hook that resolves the last editable report action for a given report. + * Used by ComposerWithSuggestions to power the arrow-up-to-edit shortcut. + */ +function useLastEditableAction(reportID: string): OnyxEntry { + const {isOffline} = useNetwork(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); + const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + const allReportTransactions = useReportTransactionsCollection(reportID); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); + const visibleTransactions = isOffline ? reportTransactions : reportTransactions?.filter((transaction) => transaction.pendingAction !== 'delete'); + const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); + + const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); + const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; + + const parentReportAction = useParentReportAction(report); + const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${effectiveTransactionThreadReportID}`); + const transactionThreadReportActionsArray = transactionThreadReportActions ? Object.values(transactionThreadReportActions) : []; + const combinedReportActions = getCombinedReportActions(filteredReportActions, effectiveTransactionThreadReportID ?? null, transactionThreadReportActionsArray); + + const route = useRoute(); + const isOnSearchMoneyRequestReport = route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT || route.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT; + const actionsForLastEditable = isOnSearchMoneyRequestReport ? filteredReportActions : combinedReportActions; + + return [...actionsForLastEditable, parentReportAction].find((action) => !isMoneyRequestAction(action) && canEditReportAction(action, undefined)); +} + +export default useLastEditableAction; diff --git a/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts b/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts new file mode 100644 index 0000000000000..c5953e1d4fc22 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts @@ -0,0 +1,120 @@ +import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; +import {useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useFilesValidation from '@hooks/useFilesValidation'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import {getFilesFromClipboardEvent} from '@libs/fileDownload/FileUtils'; +import {hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil} from '@libs/PolicyUtils'; +import {isSelfDM} from '@libs/ReportUtils'; +import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import Navigation from '@navigation/Navigation'; +import {initMoneyRequest, replaceReceipt, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt} from '@userActions/IOU'; +import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; + +type UseReceiptDropParams = { + reportID: string; + report: OnyxEntry; + shouldAddOrReplaceReceipt: boolean; + transactionID: string | undefined; +}; + +function useReceiptDrop({reportID, report, shouldAddOrReplaceReceipt, transactionID}: UseReceiptDropParams) { + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); + const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); + const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); + const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const personalPolicy = usePersonalPolicy(); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const hasOnlyPersonalPolicies = hasOnlyPersonalPoliciesUtil(allPolicies); + const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + + const isReceiptReplace = useRef(false); + + const onFilesValidated = (files: FileObject[], _dataTransferItems: DataTransferItem[]) => { + if (files.length === 0) { + return; + } + + if (isReceiptReplace.current && transactionID) { + const source = URL.createObjectURL(files.at(0) as Blob); + replaceReceipt({transactionID, file: files.at(0) as File, source, transactionPolicy: policy, transactionPolicyCategories: policyCategories}); + return; + } + + const initialTransaction = initMoneyRequest({ + reportID, + personalPolicy, + newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + report, + parentReport: newParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies, + draftTransactionIDs, + }); + + for (const [index, file] of files.entries()) { + const source = URL.createObjectURL(file as Blob); + const newTransaction = + index === 0 + ? (initialTransaction as Partial) + : buildOptimisticTransactionAndCreateDraft({ + initialTransaction: initialTransaction as Partial, + currentUserPersonalDetails, + reportID, + }); + const newTransactionID = newTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; + setMoneyRequestReceipt(newTransactionID, source, file.name ?? '', true, file.type); + setMoneyRequestParticipantsFromReport(newTransactionID, report, currentUserPersonalDetails.accountID); + } + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( + CONST.IOU.ACTION.CREATE, + isSelfDM(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT, + CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + reportID, + ), + ); + }; + + const {validateFiles, PDFValidationComponent, ErrorModal} = useFilesValidation(onFilesValidated); + + const onReceiptDropped = (e: DragEvent) => { + if (policy && shouldRestrictUserBillableActions(policy.id, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + + const files = getFilesFromClipboardEvent(e); + const items = Array.from(e.dataTransfer?.items ?? []); + + if (shouldAddOrReplaceReceipt && transactionID) { + const file = files.at(0); + if (!file) { + return; + } + + isReceiptReplace.current = true; + validateFiles([file], items); + return; + } + + isReceiptReplace.current = false; + validateFiles(files, items, {isValidatingReceipts: true}); + }; + + return {onReceiptDropped, PDFValidationComponent, ErrorModal}; +} + +export default useReceiptDrop; diff --git a/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts b/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts new file mode 100644 index 0000000000000..28b3b83947fbb --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useShouldAddOrReplaceReceipt.ts @@ -0,0 +1,49 @@ +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getFilteredReportActionsForReportView, getLinkedTransactionID, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {canEditFieldOfMoneyRequest, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, isReportTransactionThread} from '@libs/ReportUtils'; +import {getTransactionID} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; + +/** + * Determines whether the current report context allows adding or replacing a receipt, + * and resolves the relevant transactionID. + * + * Used by ComposerProvider (for upload validation) and ComposerDropZone (for drop zone layout). + */ +function useShouldAddOrReplaceReceipt(reportID: string, isOffline: boolean) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const isReportArchived = useReportIsArchived(report?.reportID); + const allReportTransactions = useReportTransactionsCollection(reportID); + const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, {canEvict: false}); + + const isTransactionThreadView = isReportTransactionThread(report); + + // We need the filtered actions to count visible transactions + const filteredReportActions = getFilteredReportActionsForReportView(rawReportActions ? Object.values(rawReportActions) : []); + 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 linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; + const transactionID = getTransactionID(report) ?? linkedTransactionID; + + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); + const isSingleTransactionView = !!transaction && !!reportTransactions && reportTransactions.length === 1; + const effectiveParentReportAction = isSingleTransactionView ? iouAction : getReportAction(report?.parentReportID, report?.parentReportActionID); + const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); + const canEditReceipt = + canUserPerformWriteAction && + canEditFieldOfMoneyRequest({reportAction: effectiveParentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction}) && + !transaction?.receipt?.isTestDriveReceipt; + const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; + + return {shouldAddOrReplaceReceipt, transactionID}; +} + +export default useShouldAddOrReplaceReceipt; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 97e5b5fcff096..4289a4d5e3559 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -54,9 +54,9 @@ import type * as OnyxTypes from '@src/types/onyx'; import findNodeHandle from '@src/utils/findNodeHandle'; import KeyboardUtils from '@src/utils/keyboard'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; +import type {SuggestionsRef} from './ReportActionCompose/ComposerContext'; import getCursorPosition from './ReportActionCompose/getCursorPosition'; import getScrollPosition from './ReportActionCompose/getScrollPosition'; -import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; import Suggestions from './ReportActionCompose/Suggestions'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; @@ -73,9 +73,6 @@ type ReportActionItemMessageEditProps = { /** ID of the original report from which the given reportAction is first created */ originalReportID: string; - /** PolicyID of the policy the report belongs to */ - policyID?: string; - /** Position index of the report action in the overall report FlatList view */ index: number; @@ -104,7 +101,6 @@ function ReportActionItemMessageEdit({ draftMessage, reportID, originalReportID, - policyID, index, isGroupPolicyReport, shouldDisableEmojiPicker = false, @@ -594,8 +590,6 @@ function ReportActionItemMessageEdit({ isComposerFocused={textInputRef.current?.isFocused()} updateComment={updateDraft} measureParentContainerAndReportCursor={measureParentContainerAndReportCursor} - isGroupPolicyReport={isGroupPolicyReport} - policyID={policyID} value={draft} selection={selection} setSelection={setSelection} diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index b0fbe7901eff9..fbab4788dbb08 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1298,6 +1298,20 @@ type OriginalMessageCard = { hadMissingAddress?: boolean; }; +/** + * Model of CARDFROZEN action + */ +type OriginalMessageCardFrozen = { + /** HTML content of the system message */ + html: string; + + /** Whether the action was generated by NewDot */ + isNewDot?: boolean; + + /** When the action was last modified */ + lastModified?: string; +}; + /** * Model of PERSONAL_CARD_CONNECTION_BROKEN action */ @@ -1352,7 +1366,7 @@ type OriginalMessageSettlementAccountLocked = { }; /** - * Original message for CARD_ISSUED, CARD_MISSING_ADDRESS, CARD_ASSIGNED, CARD_ISSUED_VIRTUAL and CARD_ISSUED_VIRTUAL actions + * Original message for Expensify Card issue/replacement actions */ type IssueNewCardOriginalMessage = OriginalMessage< | typeof CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS @@ -1469,6 +1483,8 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED_VIRTUAL]: OriginalMessageCard; [CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED]: OriginalMessageCard; [CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED]: OriginalMessageCard; + [CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN]: OriginalMessageCardFrozen; + [CONST.REPORT.ACTIONS.TYPE.CARD_UNFROZEN]: OriginalMessageCardFrozen; [CONST.REPORT.ACTIONS.TYPE.PERSONAL_CARD_CONNECTION_BROKEN]: OriginalPersonalCard; [CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED]: OriginalMessageIntegrationSyncFailed; [CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION]: OriginalMessageDeletedTransaction; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 2fda7a8fb8a8e..206bd810aac5d 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -7944,6 +7944,7 @@ describe('actions/IOU', () => { isChatIOUReportArchived: true, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8033,6 +8034,7 @@ describe('actions/IOU', () => { isChatIOUReportArchived: true, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8115,6 +8117,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8226,6 +8229,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8374,6 +8378,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8487,6 +8492,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8673,6 +8679,7 @@ describe('actions/IOU', () => { isChatIOUReportArchived: undefined, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8786,6 +8793,7 @@ describe('actions/IOU', () => { isChatIOUReportArchived: undefined, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } await waitForBatchedUpdates(); @@ -8890,6 +8898,7 @@ describe('actions/IOU', () => { isSingleTransactionView: true, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } @@ -8947,6 +8956,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } // Then we expect to navigate to the chat report @@ -9111,6 +9121,7 @@ describe('actions/IOU', () => { chatReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); } @@ -9441,6 +9452,7 @@ describe('actions/IOU', () => { describe('bulk deleteMoneyRequest', () => { const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@email.com'; it('update IOU report total properly for bulk deletion of expenses', async () => { const expenseReport: Report = { @@ -9505,6 +9517,7 @@ describe('actions/IOU', () => { selectedTransactionIDs, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); deleteMoneyRequest({ transactionID: transaction2.transactionID, @@ -9517,6 +9530,7 @@ describe('actions/IOU', () => { selectedTransactionIDs, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); await waitForBatchedUpdates(); @@ -9538,6 +9552,7 @@ describe('actions/IOU', () => { describe('deleteMoneyRequest with allTransactionViolationsParam', () => { const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@email.com'; it('should pass transaction violations to hasOutstandingChildRequest correctly', async () => { // Given an expense report with a transaction const expenseReport: Report = { @@ -9592,6 +9607,7 @@ describe('actions/IOU', () => { chatReport: expenseReport, allTransactionViolationsParam: transactionViolations, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); await waitForBatchedUpdates(); @@ -9655,6 +9671,7 @@ describe('actions/IOU', () => { chatReport: expenseReport, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, + currentUserEmail: TEST_USER_LOGIN, }); await waitForBatchedUpdates(); diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index 9e2e0edc835ee..e3b3904de27e1 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -7,7 +7,7 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {forceClearInput} from '@libs/ComponentUtils'; import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; -import ReportActionCompose, {onSubmitAction} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; +import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import * as LHNTestUtils from '../utils/LHNTestUtils'; @@ -387,8 +387,8 @@ describe('ReportActionCompose Integration Tests', () => { const validMessage = 'x'.repeat(CONST.MAX_COMMENT_LENGTH); fireEvent.changeText(composer, validMessage); - // When the message is submitted - act(onSubmitAction); + // When the message is submitted via Enter key + fireEvent(composer, 'keyPress', {key: 'Enter', shiftKey: false, preventDefault: jest.fn()}); // scheduleOnUI mock uses setTimeout(() => ..., 0) act(() => { @@ -407,8 +407,8 @@ describe('ReportActionCompose Integration Tests', () => { const invalidMessage = 'x'.repeat(CONST.MAX_COMMENT_LENGTH + 1); fireEvent.changeText(composer, invalidMessage); - // When the message is submitted - act(onSubmitAction); + // When the message is submitted via Enter key + fireEvent(composer, 'keyPress', {key: 'Enter', shiftKey: false, preventDefault: jest.fn()}); // Then the message should NOT be sent expect(mockForceClearInput).toHaveBeenCalledTimes(0); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 1f59950c971d1..20f0bdc26c1ce 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1387,6 +1387,109 @@ describe('ReportActionsUtils', () => { const expectedHtml = `${expectedText}`; expect(fragments).toEqual([{text: expectedText, html: expectedHtml, type: 'COMMENT'}]); }); + + it('should preserve backend-provided CARDFROZEN fragments', () => { + const cardFrozenMessage = 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.'; + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN, + reportActionID: 'card-frozen-action-123', + actorAccountID: 21052128, + created: '2026-03-12 01:58:43.479', + message: [ + { + html: cardFrozenMessage, + text: cardFrozenMessage, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + whisperedTo: [], + }, + ], + originalMessage: { + html: cardFrozenMessage, + isNewDot: true, + lastModified: '2026-03-12 01:58:43.479', + }, + }; + + expect(ReportActionsUtils.getReportActionMessageFragments(translateLocal, action)).toEqual(action.message); + }); + + it('should preserve backend-provided CARDUNFROZEN fragments', () => { + const cardUnfrozenMessage = 'A A unfroze their Expensify Card (ending in 1384). This card can now be used for transactions.'; + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_UNFROZEN, + reportActionID: 'card-unfrozen-action-123', + actorAccountID: 21052128, + created: '2026-03-12 02:08:08.128', + message: [ + { + html: cardUnfrozenMessage, + text: cardUnfrozenMessage, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + whisperedTo: [], + }, + ], + originalMessage: { + html: cardUnfrozenMessage, + isNewDot: true, + lastModified: '2026-03-12 02:08:08.128', + }, + }; + + expect(ReportActionsUtils.getReportActionMessageFragments(translateLocal, action)).toEqual(action.message); + }); + }); + + describe('getReportActionText', () => { + it('should return the backend-provided CARDFROZEN text', () => { + const cardFrozenMessage = 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.'; + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN, + reportActionID: 'card-frozen-action-123', + actorAccountID: 21052128, + created: '2026-03-12 01:58:43.479', + message: [ + { + html: cardFrozenMessage, + text: cardFrozenMessage, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + whisperedTo: [], + }, + ], + originalMessage: { + html: cardFrozenMessage, + isNewDot: true, + lastModified: '2026-03-12 01:58:43.479', + }, + }; + + expect(ReportActionsUtils.getReportActionText(action)).toBe(cardFrozenMessage); + }); + + it('should return the backend-provided CARDUNFROZEN text', () => { + const cardUnfrozenMessage = 'A A unfroze their Expensify Card (ending in 1384). This card can now be used for transactions.'; + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_UNFROZEN, + reportActionID: 'card-unfrozen-action-123', + actorAccountID: 21052128, + created: '2026-03-12 02:08:08.128', + message: [ + { + html: cardUnfrozenMessage, + text: cardUnfrozenMessage, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + whisperedTo: [], + }, + ], + originalMessage: { + html: cardUnfrozenMessage, + isNewDot: true, + lastModified: '2026-03-12 02:08:08.128', + }, + }; + + expect(ReportActionsUtils.getReportActionText(action)).toBe(cardUnfrozenMessage); + expect(ReportActionsUtils.shouldReportActionBeVisible(action, action.reportActionID, true)).toBe(true); + }); }); describe('getMessageOfOldDotReportAction', () => { @@ -1665,6 +1768,30 @@ describe('ReportActionsUtils', () => { const reportAction = buildOptimisticCreatedReportForUnapprovedAction('123456', '789012'); expect(ReportActionsUtils.isDeletedAction(reportAction)).toBe(false); }); + + it('should return false for CARDFROZEN action with a backend-provided message fragment', () => { + const reportAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.CARD_FROZEN, + reportActionID: 'card-frozen-action-123', + actorAccountID: 21052128, + created: '2026-03-12 01:58:43.479', + message: [ + { + html: 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.', + text: 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.', + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + whisperedTo: [], + }, + ], + originalMessage: { + html: 'A A froze their Expensify Card (ending in 1384). New transactions will be declined until the card is unfrozen.', + isNewDot: true, + lastModified: '2026-03-12 01:58:43.479', + }, + }; + + expect(ReportActionsUtils.isDeletedAction(reportAction)).toBe(false); + }); }); describe('getRenamedAction', () => { @@ -1728,7 +1855,6 @@ describe('ReportActionsUtils', () => { } as Card; const testPolicyID = 'test-policy-123'; - describe('render virtual card issued messages', () => { it('should render a plain text message without card link when no card data is available', () => { const messageResult = getCardIssuedMessage({ diff --git a/tests/unit/SuggestionMentionTest.tsx b/tests/unit/SuggestionMentionTest.tsx index e9f148640eaaf..108f6e49a7dbc 100644 --- a/tests/unit/SuggestionMentionTest.tsx +++ b/tests/unit/SuggestionMentionTest.tsx @@ -89,8 +89,6 @@ function renderSuggestionMention(value: string, updateComment = jest.fn(), selec isAutoSuggestionPickerLarge measureParentContainerAndReportCursor={() => {}} isComposerFocused - isGroupPolicyReport={false} - policyID="policyID" />, );