diff --git a/src/CONST/index.ts b/src/CONST/index.ts index fbddc79cd3bf..0ffbdfe89008 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8744,6 +8744,10 @@ const CONST = { }, }, + PORTAL_HOST_NAMES: { + CONTEXT_MENU: 'contextMenu', + }, + SENTRY_LABEL: { NAVIGATION_TAB_BAR: { EXPENSIFY_LOGO: 'NavigationTabBar-ExpensifyLogo', diff --git a/src/GlobalModals.tsx b/src/GlobalModals.tsx index b5b2b322ba1d..73b81bf78556 100644 --- a/src/GlobalModals.tsx +++ b/src/GlobalModals.tsx @@ -7,7 +7,7 @@ import ScreenShareRequestModal from './components/ScreenShareRequestModal'; import UpdateAppModal from './components/UpdateAppModal'; import * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; import {growlRef} from './libs/Growl'; -import PopoverReportActionContextMenu from './pages/inbox/report/ContextMenu/PopoverReportActionContextMenu'; +import PopoverContextMenu from './pages/inbox/report/ContextMenu/PopoverContextMenu'; import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/ReportActionContextMenu'; /** @@ -21,7 +21,7 @@ function GlobalModals() { {/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */} - + {/* eslint-disable-next-line react-hooks/refs -- module-level createRef, safe to pass as ref prop */} diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx index d27f0aa8ae67..9db9b44827f2 100644 --- a/src/components/ContextMenuItem.tsx +++ b/src/components/ContextMenuItem.tsx @@ -1,15 +1,12 @@ import React from 'react'; -import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; +import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import getButtonState from '@libs/getButtonState'; import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import BaseMiniContextMenuItem from './BaseMiniContextMenuItem'; import FocusableMenuItem from './FocusableMenuItem'; -import Icon from './Icon'; type ContextMenuItemProps = WithSentryLabel & { /** Icon Component */ @@ -24,9 +21,6 @@ type ContextMenuItemProps = WithSentryLabel & { /** Text to show when interaction was successful */ successText?: string; - /** Whether to show the mini menu */ - isMini?: boolean; - /** Callback to fire when the item is pressed */ onPress: (event?: GestureResponderEvent | MouseEvent | KeyboardEvent) => void; @@ -45,11 +39,6 @@ type ContextMenuItemProps = WithSentryLabel & { /** Styles to apply to MenuItem wrapper */ wrapperStyle?: StyleProp; - shouldPreventDefaultFocusOnPress?: boolean; - - /** The ref of mini context menu item */ - buttonRef?: React.RefObject; - /** Handles what to do when the item is focused */ onFocus?: () => void; @@ -69,14 +58,11 @@ function ContextMenuItem({ successText = '', icon, text, - isMini = false, description = '', isAnonymousAction = false, isFocused = false, shouldLimitWidth = true, wrapperStyle, - shouldPreventDefaultFocusOnPress = true, - buttonRef = {current: null}, onFocus = () => {}, onBlur = () => {}, disabled = false, @@ -104,24 +90,7 @@ function ContextMenuItem({ const itemIcon = !isThrottledButtonActive && successIcon ? successIcon : icon; const itemText = !isThrottledButtonActive && successText ? successText : text; - return isMini ? ( - - {({hovered, pressed}) => ( - - )} - - ) : ( + return ( { + if (!elementRef.current?.matches(':hover') || isHoveredRef.current || isVisibilityHidden.current) { + return; + } + updateIsHovered(true); + }, [updateIsHovered]); + const handleMouseEvents = useCallback( (type: 'enter' | 'leave') => () => { if (shouldFreezeCapture) { diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 4f230b11cbf9..6b8616293f8a 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -197,8 +197,6 @@ function OptionRowLHN({ report: { reportID, originalReportID: reportID, - isPinnedChat: optionItem.isPinned, - isUnreadChat: !!optionItem.isUnread, }, reportAction: { reportActionID: '-1', diff --git a/src/components/BaseMiniContextMenuItem.tsx b/src/components/MiniContextMenuItem.tsx similarity index 56% rename from src/components/BaseMiniContextMenuItem.tsx rename to src/components/MiniContextMenuItem.tsx index 23a4520eae8a..6bc9cb781206 100644 --- a/src/components/BaseMiniContextMenuItem.tsx +++ b/src/components/MiniContextMenuItem.tsx @@ -3,17 +3,20 @@ import type {PressableStateCallbackType} from 'react-native'; import {View} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import useThrottledButtonState from '@hooks/useThrottledButtonState'; import DomUtils from '@libs/DomUtils'; import getButtonState from '@libs/getButtonState'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import Icon from './Icon'; import type {PressableRef} from './Pressable/GenericPressable/types'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import Tooltip from './Tooltip/PopoverAnchorTooltip'; -type BaseMiniContextMenuItemProps = WithSentryLabel & { +type MiniContextMenuItemProps = WithSentryLabel & { /** * Text to display when hovering the menu item */ @@ -25,14 +28,33 @@ type BaseMiniContextMenuItemProps = WithSentryLabel & { onPress: () => void; /** - * The children to display within the menu item + * The children to display within the menu item. + * Used when custom rendering is needed (e.g. overflow button). + * Mutually exclusive with `icon`. */ - children: React.ReactNode | ((state: PressableStateCallbackType) => React.ReactNode); + children?: React.ReactNode | ((state: PressableStateCallbackType) => React.ReactNode); + + /** + * Icon to display. When provided, the component renders an Icon internally + * instead of using children. + */ + icon?: IconAsset; + + /** + * Icon to show after a successful press. Requires `icon` to be set. + * When provided, the component manages a throttled success state internally. + */ + successIcon?: IconAsset; + + /** + * Tooltip text to show during the success state. + */ + successTooltipText?: string; /** * Whether the button should be in the active state */ - isDelayButtonStateComplete: boolean; + isDelayButtonStateComplete?: boolean; /** * Can be used to control the click event, and for example whether or not to lose focus from the composer when pressing the item */ @@ -48,25 +70,45 @@ type BaseMiniContextMenuItemProps = WithSentryLabel & { * Component that renders a mini context menu item with a * pressable. Also renders a tooltip when hovering the item. */ -function BaseMiniContextMenuItem({ +function MiniContextMenuItem({ tooltipText, onPress, children, + icon, + successIcon, + successTooltipText, isDelayButtonStateComplete = true, shouldPreventDefaultFocusOnPress = true, ref, sentryLabel, -}: BaseMiniContextMenuItemProps) { +}: MiniContextMenuItemProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const [isThrottledButtonActive, setThrottledButtonInactive] = useThrottledButtonState(); + + const showSuccessState = !!successIcon && !isThrottledButtonActive; + const displayIcon = showSuccessState ? successIcon : icon; + const displayTooltip = showSuccessState && successTooltipText ? successTooltipText : tooltipText; + const isComplete = showSuccessState || isDelayButtonStateComplete; + + const handlePress = () => { + if (successIcon && !isThrottledButtonActive) { + return; + } + onPress(); + if (successIcon) { + setThrottledButtonInactive(); + } + }; + return ( { if (!ReportActionComposeFocusManager.isFocused() && !ReportActionComposeFocusManager.isEditFocused()) { const activeElement = DomUtils.getActiveElement(); @@ -86,18 +128,25 @@ function BaseMiniContextMenuItem({ event.preventDefault(); } }} - accessibilityLabel={tooltipText} + accessibilityLabel={displayTooltip} role={CONST.ROLE.BUTTON} sentryLabel={sentryLabel} style={({hovered, pressed}) => [ styles.reportActionContextMenuMiniButton, - StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, isDelayButtonStateComplete), true), - isDelayButtonStateComplete && styles.cursorDefault, + StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, isComplete), true), + isComplete && styles.cursorDefault, ]} > {(pressableState) => ( - {typeof children === 'function' ? children(pressableState) : children} + {!!displayIcon && ( + + )} + {!displayIcon && (typeof children === 'function' ? children(pressableState) : children)} )} @@ -105,4 +154,4 @@ function BaseMiniContextMenuItem({ ); } -export default BaseMiniContextMenuItem; +export default MiniContextMenuItem; diff --git a/src/components/Reactions/MiniQuickEmojiReactions.tsx b/src/components/Reactions/MiniQuickEmojiReactions.tsx index 0f562dc77116..abf12511e163 100644 --- a/src/components/Reactions/MiniQuickEmojiReactions.tsx +++ b/src/components/Reactions/MiniQuickEmojiReactions.tsx @@ -1,8 +1,8 @@ import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; import type {Emoji} from '@assets/emojis/types'; -import BaseMiniContextMenuItem from '@components/BaseMiniContextMenuItem'; import Icon from '@components/Icon'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -65,7 +65,7 @@ function MiniQuickEmojiReactions({reportAction, reportActionID, onEmojiSelected, return ( {CONST.QUICK_REACTIONS.slice(0, 3).map((emoji: Emoji) => ( - {getPreferredEmojiCode(emoji, preferredSkinTone)} - + ))} - { if (!emojiPickerRef.current?.isEmojiPickerVisible) { @@ -101,7 +101,7 @@ function MiniQuickEmojiReactions({reportAction, reportActionID, onEmojiSelected, fill={StyleUtils.getIconFillColor(getButtonState(hovered, pressed, false))} /> )} - + ); } diff --git a/src/components/ShowContextMenuContext/index.tsx b/src/components/ShowContextMenuContext/index.tsx index e468f7d0805d..25b0b33aeef3 100644 --- a/src/components/ShowContextMenuContext/index.tsx +++ b/src/components/ShowContextMenuContext/index.tsx @@ -1,8 +1,8 @@ import {createContext, useContext} from 'react'; -// eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import {getOriginalReportID} from '@libs/ReportUtils'; import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import type {ContextMenuAnchor} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; @@ -52,7 +52,7 @@ function showContextMenuForReport( contextMenuAnchor: anchor, report: { reportID, - originalReportID: originalReportID ?? reportID, + originalReportID: originalReportID ?? (reportID ? getOriginalReportID(reportID, action, undefined) : undefined), isArchivedRoom, }, reportAction: { diff --git a/src/libs/API/parameters/MarkAsUnreadParams.ts b/src/libs/API/parameters/MarkAsUnreadParams.ts index 56ab9bf563ea..36615527f497 100644 --- a/src/libs/API/parameters/MarkAsUnreadParams.ts +++ b/src/libs/API/parameters/MarkAsUnreadParams.ts @@ -1,7 +1,7 @@ type MarkAsUnreadParams = { reportID: string; lastReadTime: string; - reportActionID: string; + reportActionID?: string; }; export default MarkAsUnreadParams; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index cc96dc1cecba..74124a753046 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -2364,7 +2364,7 @@ function readNewestAction(reportID: string | undefined, hasOnceLoadedReportActio /** * Sets the last read time on a report */ -function markCommentAsUnread(reportID: string | undefined, reportActions: OnyxEntry, reportAction: ReportAction, currentUserAccountID: number) { +function markCommentAsUnread(reportID: string | undefined, reportActions: OnyxEntry, reportAction: ReportAction | undefined, currentUserAccountID: number) { if (!reportID) { Log.warn('7339cd6c-3263-4f89-98e5-730f0be15784 Invalid report passed to MarkCommentAsUnread. Not calling the API because it wil fail.'); return; @@ -2391,7 +2391,7 @@ function markCommentAsUnread(reportID: string | undefined, reportActions: OnyxEn // If no action created date is provided, use the last action's from other user const actionCreationTime = - reportAction?.created || (latestReportActionFromOtherUsers?.created ?? getReportLastVisibleActionCreated(report, transactionThreadReport) ?? DateUtils.getDBTime(0)); + reportAction?.created ?? latestReportActionFromOtherUsers?.created ?? getReportLastVisibleActionCreated(report, transactionThreadReport) ?? DateUtils.getDBTime(0); // We subtract 1 millisecond so that the lastReadTime is updated to just before a given reportAction's created date // For example, if we want to mark a report action with ID 100 and created date '2014-04-01 16:07:02.999' unread, we set the lastReadTime to '2014-04-01 16:07:02.998' diff --git a/src/libs/interceptAnonymousUser.ts b/src/libs/interceptAnonymousUser.ts index d4e40cf44779..679d1fd86ad6 100644 --- a/src/libs/interceptAnonymousUser.ts +++ b/src/libs/interceptAnonymousUser.ts @@ -1,16 +1,17 @@ -import * as Session from './actions/Session'; +import {InteractionManager} from 'react-native'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import {isAnonymousUser, signOutAndRedirectToSignIn} from './actions/Session'; -/** - * Checks if user is anonymous. If true, shows the sign in modal, else, - * executes the callback. - */ -const interceptAnonymousUser = (callback: () => void) => { - const isAnonymousUser = Session.isAnonymousUser(); - if (isAnonymousUser) { - Session.signOutAndRedirectToSignIn(); +function interceptAnonymousUser(callback: () => void, isAnonymousAction = false) { + if (isAnonymousUser() && !isAnonymousAction) { + hideContextMenu(false); + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + signOutAndRedirectToSignIn(); + }); } else { callback(); } -}; +} export default interceptAnonymousUser; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 44fa007a214b..4960ee09d9bf 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -1,7 +1,7 @@ import {PortalHost} from '@gorhom/portal'; import React from 'react'; import type {ViewStyle} from 'react-native'; -import {View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import ScreenWrapper from '@components/ScreenWrapper'; import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; import useActionListContextValue from '@hooks/useActionListContextValue'; @@ -20,6 +20,8 @@ import {AgentZeroStatusProvider} from './AgentZeroStatusContext'; import DeleteTransactionNavigateBackHandler from './DeleteTransactionNavigateBackHandler'; import LinkedActionNotFoundGuard from './LinkedActionNotFoundGuard'; import ReactionListWrapper from './ReactionListWrapper'; +import {MiniContextMenuProvider} from './report/ContextMenu/MiniContextMenuProvider'; +import MiniReportActionContextMenu from './report/ContextMenu/MiniReportActionContextMenu'; import ReportFooter from './report/ReportFooter'; import ReportActionsList from './ReportActionsList'; import ReportDragAndDropProvider from './ReportDragAndDropProvider'; @@ -82,7 +84,16 @@ function ReportScreen({route, navigation}: ReportScreenProps) { style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]} testID="report-actions-view-wrapper" > - + + + + + + + diff --git a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx deleted file mode 100755 index 969ff12cdb6b..000000000000 --- a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx +++ /dev/null @@ -1,469 +0,0 @@ -import {hasSeenTourSelector} from '@selectors/Onboarding'; -import {deepEqual} from 'fast-equals'; -import type {RefObject} from 'react'; -import React, {memo, useMemo, useRef, useState} from 'react'; -import {InteractionManager, View} from 'react-native'; -// eslint-disable-next-line no-restricted-imports -import type {GestureResponderEvent, Text as RNText, View as ViewType} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; -import ContextMenuItem from '@components/ContextMenuItem'; -import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; -import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; -import {useSession} from '@components/OnyxListItemProvider'; -import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useEnvironment from '@hooks/useEnvironment'; -import useGetExpensifyCardFromReportAction from '@hooks/useGetExpensifyCardFromReportAction'; -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 useReportIsArchived from '@hooks/useReportIsArchived'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useRestoreInputFocus from '@hooks/useRestoreInputFocus'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getMovedReportID} from '@libs/ModifiedExpenseMessage'; -import {getLinkedTransactionID, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, isDeletedAction, withDEWRoutedActionsObject} from '@libs/ReportActionsUtils'; -import { - chatIncludesChronosWithID, - getHarvestOriginalReportID, - getSourceIDFromReportAction, - isArchivedNonExpenseReport, - isHarvestCreatedExpenseReport, - isInvoiceReport as ReportUtilsIsInvoiceReport, - isMoneyRequest as ReportUtilsIsMoneyRequest, - isMoneyRequestReport as ReportUtilsIsMoneyRequestReport, - isTrackExpenseReport as ReportUtilsIsTrackExpenseReport, -} from '@libs/ReportUtils'; -import {isAnonymousUser, signOutAndRedirectToSignIn} from '@userActions/Session'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {OriginalMessageIOU, ReportAction} from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {ContextMenuAction, ContextMenuActionPayload} from './ContextMenuActions'; -import ContextMenuActions from './ContextMenuActions'; -import type {ContextMenuAnchor, ContextMenuType} from './ReportActionContextMenu'; -import {hideContextMenu, showContextMenu} from './ReportActionContextMenu'; - -type BaseReportActionContextMenuProps = { - /** The ID of the report this report action is attached to. */ - reportID: string | undefined; - - /** The ID of the report action this context menu is attached to. */ - reportActionID: string | undefined; - - /** The ID of the original report from which the given reportAction is first created. */ - originalReportID: string | undefined; - - /** - * If true, this component will be a small, row-oriented menu that displays icons but not text. - * If false, this component will be a larger, column-oriented menu that displays icons alongside text in each row. - */ - isMini?: boolean; - - /** Controls the visibility of this component. */ - isVisible?: boolean; - - /** The copy selection. */ - selection?: string; - - /** Draft message - if this is set the comment is in 'edit' mode */ - draftMessage?: string; - - /** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */ - type?: ContextMenuType; - - /** Target node which is the target of ContentMenu */ - anchor?: RefObject; - - /** Flag to check if the chat participant is Chronos */ - isChronosReport?: boolean; - - /** Whether the provided report is an archived room */ - isArchivedRoom?: boolean; - - /** Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action */ - isPinnedChat?: boolean; - - /** Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action */ - isUnreadChat?: boolean; - - /** - * Is the action a thread's parent reportAction viewed from within the thread report? - * It will be false if we're viewing the same parent report action from the report it belongs to rather than the thread. - */ - isThreadReportParentAction?: boolean; - - /** Content Ref */ - contentRef?: RefObject; - - /** Function to check if context menu is active */ - checkIfContextMenuActive?: () => void; - - /** List of disabled actions */ - disabledActions?: ContextMenuAction[]; - - /** Function to update emoji picker state */ - setIsEmojiPickerActive?: (state: boolean) => void; -}; - -function BaseReportActionContextMenu({ - type = CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - anchor, - contentRef, - isChronosReport = false, - isArchivedRoom = false, - isMini = false, - isVisible = false, - isPinnedChat = false, - isUnreadChat = false, - isThreadReportParentAction = false, - selection = '', - draftMessage = '', - reportActionID, - reportID, - originalReportID, - checkIfContextMenuActive, - disabledActions = [], - setIsEmojiPickerActive, -}: BaseReportActionContextMenuProps) { - const {transitionActionSheetState} = ActionSheetAwareScrollView.useActionSheetAwareScrollViewActions(); - const {isDelegateAccessRestricted} = useDelegateNoAccessState(); - const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const icons = useMemoizedLazyExpensifyIcons([ - 'Bell', - 'Bug', - 'ChatBubbleReply', - 'ChatBubbleUnread', - 'Checkmark', - 'Concierge', - 'Copy', - 'Download', - 'Exit', - 'Flag', - 'LinkCopy', - 'Mail', - 'Pencil', - 'Pin', - 'Stopwatch', - 'ThreeDots', - 'Trashcan', - ]); - const StyleUtils = useStyleUtils(); - const {translate, getLocalDateFromDatetime} = useLocalize(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); - const [shouldKeepOpen, setShouldKeepOpen] = useState(false); - const wrapperStyle = StyleUtils.getReportActionContextMenuStyles(isMini, shouldUseNarrowLayout); - const {isOffline} = useNetwork(); - const {isProduction} = useEnvironment(); - const threeDotRef = useRef(null); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { - canEvict: false, - selector: withDEWRoutedActionsObject, - }); - const [originalReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, { - canEvict: false, - selector: withDEWRoutedActionsObject, - }); - - const reportAction: OnyxEntry = useMemo(() => { - if (isEmptyObject(originalReportActions) || reportActionID === '0' || reportActionID === '-1' || !reportActionID) { - return; - } - return originalReportActions[reportActionID]; - }, [originalReportActions, reportActionID]); - const transactionID = getLinkedTransactionID(reportAction); - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); - const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(reportID)}`); - const [harvestReport] = useOnyx( - `${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(getHarvestOriginalReportID(reportNameValuePairs?.origin, reportNameValuePairs?.originalID))}`, - {}, - ); - const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`); - const isOriginalReportArchived = useReportIsArchived(originalReportID); - const policyID = report?.policyID; - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); - - const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(reportAction, CONST.REPORT.MOVE_TYPE.FROM)}`); - const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(reportAction, CONST.REPORT.MOVE_TYPE.TO)}`); - - const sourceID = getSourceIDFromReportAction(reportAction); - - const [download] = useOnyx(`${ONYXKEYS.COLLECTION.DOWNLOAD}${sourceID}`); - - const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction?.childReportID}`); - const [childReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportAction?.childReportID}`); - const [childChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${childReport?.chatReportID}`); - const parentReportAction = getReportAction(childReport?.parentReportID, childReport?.parentReportActionID); - const {reportActions: paginatedReportActions} = usePaginatedReportActions(childReport?.reportID); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const transactionThreadReportID = useMemo( - () => getOneTransactionThreadReportID(childReport, childChatReport, paginatedReportActions ?? [], isOffline), - [paginatedReportActions, isOffline, childReport, childChatReport], - ); - - const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionThreadReportID)}`); - - const isMoneyRequestReport = useMemo(() => ReportUtilsIsMoneyRequestReport(childReport), [childReport]); - const isInvoiceReport = useMemo(() => ReportUtilsIsInvoiceReport(childReport), [childReport]); - - const requestParentReportAction = useMemo(() => { - if (isMoneyRequestReport || isInvoiceReport) { - if (transactionThreadReportID === CONST.FAKE_REPORT_ID) { - return Object.values(childReportActions ?? {}).find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !isDeletedAction(action)); - } - if (!paginatedReportActions || !transactionThreadReport?.parentReportActionID) { - return undefined; - } - return paginatedReportActions.find((action) => action.reportActionID === transactionThreadReport.parentReportActionID); - } - return parentReportAction; - }, [parentReportAction, isMoneyRequestReport, isInvoiceReport, paginatedReportActions, transactionThreadReport?.parentReportActionID, transactionThreadReportID, childReportActions]); - - const moneyRequestAction = transactionThreadReportID ? requestParentReportAction : parentReportAction; - const isChildReportArchived = useReportIsArchived(childReport?.reportID); - const isParentReportArchived = useReportIsArchived(childReport?.parentReportID); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${childReport?.parentReportID}`); - const iouTransactionID = (getOriginalMessage(moneyRequestAction ?? reportAction) as OriginalMessageIOU)?.IOUTransactionID; - const [iouTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); - const iouReportID = (getOriginalMessage(moneyRequestAction ?? reportAction) as OriginalMessageIOU)?.IOUReportID; - const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`); - const [moneyRequestPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport?.policyID}`); - const {transactions} = useTransactionsAndViolationsForReport(childReport?.reportID); - const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); - const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - - const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; - const session = useSession(); - const encryptedAuthToken = session?.encryptedAuthToken ?? ''; - - const isMoneyRequest = useMemo(() => ReportUtilsIsMoneyRequest(childReport), [childReport]); - const isTrackExpenseReport = ReportUtilsIsTrackExpenseReport(childReport); - const isSingleTransactionView = isMoneyRequest || isTrackExpenseReport; - const isMoneyRequestOrReport = isMoneyRequestReport || isSingleTransactionView; - - const areHoldRequirementsMet = - !isInvoiceReport && - isMoneyRequestOrReport && - !isArchivedNonExpenseReport(transactionThreadReportID ? childReport : parentReport, transactionThreadReportID ? isChildReportArchived : isParentReportArchived); - - const shouldEnableArrowNavigation = !isMini && (isVisible || shouldKeepOpen); - const isHarvestReport = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); - - let filteredContextMenuActions = ContextMenuActions.filter( - (contextAction) => - !disabledActions.includes(contextAction) && - contextAction.shouldShow({ - type, - reportAction, - childReportActions, - isArchivedRoom, - betas, - menuTarget: anchor, - isChronosReport, - reportID, - isPinnedChat, - isUnreadChat, - isThreadReportParentAction, - isOffline: !!isOffline, - isMini, - isProduction, - moneyRequestReport, - moneyRequestAction, - moneyRequestPolicy, - areHoldRequirementsMet, - isDebugModeEnabled, - iouTransaction, - transactions, - isHarvestReport, - currentUserAccountID: currentUserPersonalDetails?.accountID, - }), - ); - - if (isMini) { - const menuAction = filteredContextMenuActions.at(-1); - const otherActions = filteredContextMenuActions.slice(0, -1); - if (otherActions.length > CONST.MINI_CONTEXT_MENU_MAX_ITEMS && menuAction) { - filteredContextMenuActions = otherActions.slice(0, CONST.MINI_CONTEXT_MENU_MAX_ITEMS - 1); - filteredContextMenuActions.push(menuAction); - } else { - filteredContextMenuActions = otherActions; - } - } - - // Context menu actions that are not rendered as menu items are excluded from arrow navigation - const nonMenuItemActionIndexes = filteredContextMenuActions.map((contextAction, index) => - 'renderContent' in contextAction && typeof contextAction.renderContent === 'function' ? index : undefined, - ); - const disabledIndexes = nonMenuItemActionIndexes.filter((index): index is number => index !== undefined); - - const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ - initialFocusedIndex: -1, - disabledIndexes, - maxIndex: filteredContextMenuActions.length - 1, - isActive: shouldEnableArrowNavigation, - }); - - /** - * Checks if user is anonymous. If true and the action doesn't accept for anonymous user, hides the context menu and - * shows the sign in modal. Else, executes the callback. - */ - const interceptAnonymousUser = (callback: () => void, isAnonymousAction = false) => { - if (isAnonymousUser() && !isAnonymousAction) { - hideContextMenu(false); - - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - signOutAndRedirectToSignIn(); - }); - } else { - callback(); - } - }; - - useRestoreInputFocus(isVisible); - - const openOverflowMenu = (event: GestureResponderEvent | MouseEvent, anchorRef: RefObject) => { - showContextMenu({ - type: CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - selection, - contextMenuAnchor: anchorRef?.current as ViewType | RNText | null, - report: { - reportID, - originalReportID, - isArchivedRoom: isArchivedNonExpenseReport(originalReport, isOriginalReportArchived), - isChronos: chatIncludesChronosWithID(originalReportID), - }, - reportAction: { - reportActionID: reportAction?.reportActionID, - draftMessage, - isThreadReportParentAction, - }, - callbacks: { - onShow: checkIfContextMenuActive, - onHide: () => { - checkIfContextMenuActive?.(); - setShouldKeepOpen(false); - }, - }, - disabledOptions: filteredContextMenuActions, - shouldCloseOnTarget: true, - isOverflowMenu: true, - }); - }; - - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - const card = useGetExpensifyCardFromReportAction({reportAction: (reportAction ?? null) as ReportAction, policyID}); - - return ( - (isVisible || shouldKeepOpen || !isMini) && ( - - - {filteredContextMenuActions.map((contextAction, index) => { - const closePopup = !isMini; - const payload: ContextMenuActionPayload = { - reportActions, - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - reportAction: (reportAction ?? null) as ReportAction, - reportID, - originalReportID, - report, - draftMessage, - selection, - close: () => setShouldKeepOpen(false), - transitionActionSheetState, - openContextMenu: () => setShouldKeepOpen(true), - interceptAnonymousUser, - openOverflowMenu, - setIsEmojiPickerActive, - isHarvestReport, - moneyRequestAction, - card, - originalReport, - isTryNewDotNVPDismissed, - childReport, - movedFromReport, - movedToReport, - getLocalDateFromDatetime, - policy, - policyTags, - translate, - harvestReport, - introSelected, - isSelfTourViewed, - betas, - isDelegateAccessRestricted, - showDelegateNoAccessModal, - currentUserAccountID: currentUserPersonalDetails?.accountID, - currentUserPersonalDetails, - encryptedAuthToken, - iouTransaction, - bankAccountList, - isOffline, - conciergeReportID, - }; - - if ('renderContent' in contextAction) { - return contextAction.renderContent(closePopup, payload); - } - - const {textTranslateKey} = contextAction; - const isKeyInActionUpdateKeys = textTranslateKey === 'reportActionContextMenu.editAction' || textTranslateKey === 'reportActionContextMenu.deleteConfirmation'; - const text = textTranslateKey && (isKeyInActionUpdateKeys ? translate(textTranslateKey, {action: moneyRequestAction ?? reportAction}) : translate(textTranslateKey)); - const transactionPayload = textTranslateKey === 'reportActionContextMenu.copyMessage' && transaction && {transaction}; - const isMenuAction = textTranslateKey === 'reportActionContextMenu.menu'; - const successIcon = contextAction.successIcon ? icons[contextAction.successIcon] : undefined; - - return ( - - interceptAnonymousUser( - () => contextAction.onPress?.(closePopup, {...payload, ...transactionPayload, event, ...(isMenuAction ? {anchorRef: threeDotRef} : {})}), - contextAction.isAnonymousAction, - ) - } - description={contextAction.getDescription?.(selection) ?? ''} - isAnonymousAction={contextAction.isAnonymousAction} - isFocused={focusedIndex === index} - shouldPreventDefaultFocusOnPress={contextAction.shouldPreventDefaultFocusOnPress} - onFocus={() => setFocusedIndex(index)} - onBlur={() => (index === filteredContextMenuActions.length - 1 || index === 1) && setFocusedIndex(-1)} - disabled={contextAction?.shouldDisable ? contextAction?.shouldDisable(download) : false} - shouldShowLoadingSpinnerIcon={contextAction?.shouldDisable ? contextAction?.shouldDisable(download) : false} - sentryLabel={contextAction.sentryLabel} - /> - ); - })} - - - ) - ); -} - -// eslint-disable-next-line rulesdir/no-deep-equal-in-memo -export default memo(BaseReportActionContextMenu, deepEqual); - -export type {BaseReportActionContextMenuProps}; diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx deleted file mode 100644 index 8484702fa9af..000000000000 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ /dev/null @@ -1,1386 +0,0 @@ -import {Str} from 'expensify-common'; -import type {RefObject} from 'react'; -import React from 'react'; -import type {GestureResponderEvent, View} from 'react-native'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import type {Emoji} from '@assets/emojis/types'; -import type {ExpensifyIconName} from '@components/Icon/ExpensifyIconLoader'; -import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; -import MiniQuickEmojiReactions from '@components/Reactions/MiniQuickEmojiReactions'; -import QuickEmojiReactions from '@components/Reactions/QuickEmojiReactions'; -import type useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; -import {isMobileSafari} from '@libs/Browser'; -import Clipboard from '@libs/Clipboard'; -import getClipboardText from '@libs/Clipboard/getClipboardText'; -import EmailUtils from '@libs/EmailUtils'; -import {getEnvironmentURL} from '@libs/Environment/Environment'; -import fileDownload from '@libs/fileDownload'; -import getAttachmentDetails from '@libs/fileDownload/getAttachmentDetails'; -import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from '@libs/LocalePhoneNumber'; -import {getForReportAction} from '@libs/ModifiedExpenseMessage'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; -import Navigation from '@libs/Navigation/Navigation'; -import Parser from '@libs/Parser'; -import {getCleanedTagName, isPolicyAdmin} from '@libs/PolicyUtils'; -import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; -import stripFollowupListFromHtml from '@libs/ReportActionFollowupUtils/stripFollowupListFromHtml'; -import { - getActionableCard3DSTransactionApprovalMessage, - getActionableCardFraudAlertMessage, - getActionableMentionWhisperMessage, - getAddedApprovalRuleMessage, - getAddedBudgetMessage, - getAddedCardFeedMessage, - getAddedConnectionMessage, - getAssignedCompanyCardMessage, - getAutoPayApprovedReportsEnabledMessage, - getAutoReimbursementMessage, - getCardIssuedMessage, - getChangedApproverActionMessage, - getCompanyAddressUpdateMessage, - getCompanyCardConnectionBrokenMessage, - getCreatedReportForUnapprovedTransactionsMessage, - getCurrencyDefaultTaxUpdateMessage, - getCustomTaxNameUpdateMessage, - getDefaultApproverUpdateMessage, - getDeletedApprovalRuleMessage, - getDeletedBudgetMessage, - getDismissedViolationMessageText, - getDynamicExternalWorkflowApproveFailedActionMessage, - getDynamicExternalWorkflowRoutedMessage, - getDynamicExternalWorkflowSubmitFailedActionMessage, - getExportIntegrationMessageHTML, - getForeignCurrencyDefaultTaxUpdateMessage, - getForwardsToUpdateMessage, - getHarvestCreatedExpenseReportMessage, - getIntegrationSyncFailedMessage, - getInvoiceCompanyNameUpdateMessage, - getInvoiceCompanyWebsiteUpdateMessage, - getIOUReportIDFromReportActionPreview, - getJoinRequestMessage, - getMarkedReimbursedMessage, - getMemberChangeMessageFragment, - getMessageOfOldDotReportAction, - getOriginalMessage, - getPlaidBalanceFailureMessage, - getPolicyChangeLogAddEmployeeMessage, - getPolicyChangeLogDefaultBillableMessage, - getPolicyChangeLogDefaultReimbursableMessage, - getPolicyChangeLogDefaultTitleEnforcedMessage, - getPolicyChangeLogDeleteMemberMessage, - getPolicyChangeLogMaxExpenseAgeMessage, - getPolicyChangeLogMaxExpenseAmountMessage, - getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, - getPolicyChangeLogUpdateEmployee, - getReimburserUpdateMessage, - getRemovedCardFeedMessage, - getRemovedConnectionMessage, - getRenamedAction, - getRenamedCardFeedMessage, - getReportAction, - getReportActionMessageFragments, - getReportActionMessageText, - getRoomAvatarUpdatedMessage, - getSetAutoJoinMessage, - getSettlementAccountLockedMessage, - getSubmitsToUpdateMessage, - getTagListNameUpdatedMessage, - getTagListUpdatedMessage, - getTagListUpdatedRequiredMessage, - getTravelUpdateMessage, - getUnassignedCompanyCardMessage, - getUpdateACHAccountMessage, - getUpdatedApprovalRuleMessage, - getUpdatedAuditRateMessage, - getUpdatedAutoHarvestingMessage, - getUpdatedBudgetMessage, - getUpdatedCardFeedLiabilityMessage, - getUpdatedCardFeedStatementPeriodMessage, - getUpdatedDefaultTitleMessage, - getUpdatedIndividualBudgetNotificationMessage, - getUpdatedManualApprovalThresholdMessage, - getUpdatedOwnershipMessage, - getUpdatedProhibitedExpensesMessage, - getUpdatedReimbursementChoiceMessage, - getUpdatedSharedBudgetNotificationMessage, - getUpdatedTimeEnabledMessage, - getUpdatedTimeRateMessage, - getUpdateRoomDescriptionMessage, - getWorkspaceAttendeeTrackingUpdateMessage, - getWorkspaceCategoriesUpdatedMessage, - getWorkspaceCategoryUpdateMessage, - getWorkspaceCurrencyUpdateMessage, - getWorkspaceCustomUnitRateAddedMessage, - getWorkspaceCustomUnitRateDeletedMessage, - getWorkspaceCustomUnitRateImportedMessage, - getWorkspaceCustomUnitRateUpdatedMessage, - getWorkspaceCustomUnitSubRateDeletedMessage, - getWorkspaceCustomUnitSubRateUpdatedMessage, - getWorkspaceCustomUnitUpdatedMessage, - getWorkspaceDescriptionUpdatedMessage, - getWorkspaceFeatureEnabledMessage, - getWorkspaceFrequencyUpdateMessage, - getWorkspaceReimbursementUpdateMessage, - getWorkspaceReportFieldAddMessage, - getWorkspaceReportFieldDeleteMessage, - getWorkspaceReportFieldUpdateMessage, - getWorkspaceTagUpdateMessage, - getWorkspaceTaxUpdateMessage, - getWorkspaceUpdateFieldMessage, - hasReasoning, - isActionableJoinRequest, - isActionableMentionWhisper, - isActionableTrackExpense, - isActionOfType, - isCardIssuedAction, - isCreatedAction, - isCreatedTaskReportAction, - isDeletedAction as isDeletedActionReportActionsUtils, - isDynamicExternalWorkflowApproveFailedAction, - isDynamicExternalWorkflowSubmitFailedAction, - isMarkAsClosedAction, - isMemberChangeAction, - isMessageDeleted, - isModifiedExpenseAction, - isMoneyRequestAction, - isMovedAction, - isOldDotReportAction, - isOriginalReportDeleted, - isReimbursementDeQueuedOrCanceledAction, - isReimbursementQueuedAction, - isRejectedAction, - isRenamedAction, - isReportActionAttachment, - isReportPreviewAction as isReportPreviewActionReportActionsUtils, - isTagModificationAction, - isTaskAction as isTaskActionReportActionsUtils, - isTripPreview, - isUnapprovedAction, - isWhisperAction as isWhisperActionReportActionsUtils, -} from '@libs/ReportActionsUtils'; -import {getReportName} from '@libs/ReportNameUtils'; -import { - canDeleteReportAction, - canEditReportAction, - canFlagReportAction, - canHoldUnholdReportAction, - changeMoneyRequestHoldStatus, - getChildReportNotificationPreference as getChildReportNotificationPreferenceReportUtils, - getDeletedTransactionMessage, - getIOUReportActionDisplayMessage, - getMovedActionMessage, - getMovedTransactionMessage, - getPolicyChangeMessage, - getReimbursementDeQueuedOrCanceledActionMessage, - getReimbursementQueuedActionMessage, - getReportName as getReportNameDeprecated, - getReportOrDraftReport, - getReportPreviewMessage, - getUnreportedTransactionMessage, - getWorkspaceNameUpdatedMessage, - isExpenseReport, - shouldDisableThread, - shouldDisplayThreadReplies as shouldDisplayThreadRepliesReportUtils, -} from '@libs/ReportUtils'; -import {getTaskCreatedMessage, getTaskReportActionMessage} from '@libs/TaskUtils'; -import {setDownload} from '@userActions/Download'; -import { - deleteReportActionDraft, - explain, - markCommentAsUnread, - navigateToAndOpenChildReport, - openReport, - readNewestAction, - saveReportActionDraft, - toggleEmojiReaction, - togglePinnedState, - toggleSubscribeToChildReport, -} from '@userActions/Report'; -import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type { - BankAccountList, - Beta, - Card, - Download as DownloadOnyx, - IntroSelected, - OnyxInputOrEntry, - Policy, - PolicyTagLists, - ReportAction, - ReportActionReactions, - ReportActions, - Report as ReportType, - Transaction, -} from '@src/types/onyx'; -import type WithSentryLabel from '@src/types/utils/SentryLabel'; -import KeyboardUtils from '@src/utils/keyboard'; -import type {ContextMenuAnchor} from './ReportActionContextMenu'; -import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; - -/** Gets the HTML version of the message in an action */ -function getActionHtml(reportAction: OnyxInputOrEntry): string { - const message = Array.isArray(reportAction?.message) ? (reportAction?.message?.at(-1) ?? null) : (reportAction?.message ?? null); - return message?.html ?? ''; -} - -/** Sets the HTML string to Clipboard */ -function setClipboardMessage(content: string | undefined) { - const strippedContent = stripFollowupListFromHtml(content); - if (!strippedContent) { - return; - } - const clipboardText = getClipboardText(strippedContent); - if (!Clipboard.canSetHtml()) { - Clipboard.setString(clipboardText); - } else { - Clipboard.setHtml(strippedContent, clipboardText); - } -} - -type ShouldShow = (args: { - type: string; - reportAction: OnyxEntry; - childReportActions: OnyxCollection; - isArchivedRoom: boolean; - betas: OnyxEntry; - menuTarget: RefObject | undefined; - isChronosReport: boolean; - reportID?: string; - isPinnedChat: boolean; - isUnreadChat: boolean; - isThreadReportParentAction: boolean; - isOffline: boolean; - isMini: boolean; - isProduction: boolean; - moneyRequestAction: ReportAction | undefined; - areHoldRequirementsMet: boolean; - isDebugModeEnabled: OnyxEntry; - iouTransaction: OnyxEntry; - transactions?: OnyxCollection; - moneyRequestReport?: OnyxEntry; - moneyRequestPolicy?: OnyxEntry; - isHarvestReport?: boolean; - currentUserAccountID: number; -}) => boolean; - -type ContextMenuActionPayload = { - reportActions: OnyxEntry; - reportAction: ReportAction; - transaction?: OnyxEntry; - reportID: string | undefined; - originalReportID: string | undefined; - currentUserAccountID: number; - report: OnyxEntry; - policy?: OnyxEntry; - draftMessage: string; - selection: string; - close: () => void; - transitionActionSheetState: (params: {type: string; payload?: Record}) => void; - openContextMenu: () => void; - interceptAnonymousUser: (callback: () => void, isAnonymousAction?: boolean) => void; - anchor?: RefObject; - checkIfContextMenuActive?: () => void; - openOverflowMenu: (event: GestureResponderEvent | MouseEvent, anchorRef: RefObject) => void; - event?: GestureResponderEvent | MouseEvent | KeyboardEvent; - setIsEmojiPickerActive?: (state: boolean) => void; - anchorRef?: RefObject; - moneyRequestAction: ReportAction | undefined; - card?: Card; - originalReport: OnyxEntry; - isHarvestReport?: boolean; - isTryNewDotNVPDismissed?: boolean; - childReport?: OnyxEntry; - movedFromReport?: OnyxEntry; - movedToReport?: OnyxEntry; - getLocalDateFromDatetime: LocaleContextProps['getLocalDateFromDatetime']; - policyTags: OnyxEntry; - translate: LocalizedTranslate; - harvestReport?: OnyxEntry; - introSelected: OnyxEntry; - isSelfTourViewed: boolean | undefined; - betas: OnyxEntry; - isDelegateAccessRestricted?: boolean; - showDelegateNoAccessModal?: () => void; - currentUserPersonalDetails: ReturnType; - encryptedAuthToken: string; - iouTransaction: OnyxEntry; - bankAccountList: OnyxEntry; - isOffline: boolean; - conciergeReportID: string | undefined; -}; - -type OnPress = (closePopover: boolean, payload: ContextMenuActionPayload, selection?: string, reportID?: string, draftMessage?: string) => void; - -type RenderContent = (closePopover: boolean, payload: ContextMenuActionPayload) => React.ReactElement; - -type GetDescription = (selection?: string) => string | void; - -type ContextMenuActionWithContent = { - renderContent: RenderContent; -}; - -type ContextMenuActionWithIcon = WithSentryLabel & { - textTranslateKey: TranslationPaths; - icon: Extract< - ExpensifyIconName, - | 'Download' - | 'ThreeDots' - | 'ChatBubbleReply' - | 'ChatBubbleUnread' - | 'Mail' - | 'Pencil' - | 'Stopwatch' - | 'Bell' - | 'Copy' - | 'LinkCopy' - | 'Pin' - | 'Flag' - | 'Bug' - | 'Trashcan' - | 'Exit' - | 'Concierge' - >; - successTextTranslateKey?: TranslationPaths; - successIcon?: Extract< - ExpensifyIconName, - | 'Download' - | 'ChatBubbleReply' - | 'ChatBubbleUnread' - | 'Checkmark' - | 'Mail' - | 'Pencil' - | 'Stopwatch' - | 'Bell' - | 'Copy' - | 'LinkCopy' - | 'Pin' - | 'Flag' - | 'Bug' - | 'Trashcan' - | 'ThreeDots' - | 'Concierge' - >; - onPress: OnPress; - getDescription: GetDescription; -}; - -type ContextMenuAction = (ContextMenuActionWithContent | ContextMenuActionWithIcon) & { - isAnonymousAction: boolean; - shouldShow: ShouldShow; - shouldPreventDefaultFocusOnPress?: boolean; - shouldDisable?: (download: OnyxEntry) => boolean; -}; - -// A list of all the context actions in this menu. -const ContextMenuActions: ContextMenuAction[] = [ - { - isAnonymousAction: false, - shouldShow: ({type, reportAction}) => { - const isDynamicWorkflowRoutedAction = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED); - return type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !!reportAction && 'message' in reportAction && !isMessageDeleted(reportAction) && !isDynamicWorkflowRoutedAction; - }, - renderContent: (closePopover, {reportID, reportAction, currentUserAccountID, close: closeManually, openContextMenu, setIsEmojiPickerActive}) => { - const isMini = !closePopover; - - const closeContextMenu = (onHideCallback?: () => void) => { - if (isMini) { - closeManually(); - if (onHideCallback) { - onHideCallback(); - } - } else { - hideContextMenu(false, onHideCallback); - } - }; - - const toggleEmojiAndCloseMenu = (emoji: Emoji, existingReactions: OnyxEntry, preferredSkinTone: number) => { - toggleEmojiReaction(reportID, reportAction, emoji, existingReactions, preferredSkinTone, currentUserAccountID); - closeContextMenu(); - setIsEmojiPickerActive?.(false); - }; - - if (isMini) { - return ( - { - openContextMenu(); - setIsEmojiPickerActive?.(true); - }} - onEmojiPickerClosed={() => { - closeContextMenu(); - setIsEmojiPickerActive?.(false); - }} - reportActionID={reportAction?.reportActionID} - reportAction={reportAction} - /> - ); - } - - return ( - - ); - }, - }, - { - isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.replyInThread', - icon: 'ChatBubbleReply', - shouldShow: ({type, reportAction, reportID, isThreadReportParentAction, isArchivedRoom}) => { - if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION || !reportID) { - return false; - } - return !shouldDisableThread(reportAction, isThreadReportParentAction, isArchivedRoom); - }, - onPress: (closePopover, {reportAction, childReport, originalReport, currentUserAccountID, introSelected, betas}) => { - if (closePopover) { - hideContextMenu(false, () => { - KeyboardUtils.dismiss().then(() => { - navigateToAndOpenChildReport(childReport, reportAction, originalReport, currentUserAccountID, introSelected, betas); - }); - }); - return; - } - navigateToAndOpenChildReport(childReport, reportAction, originalReport, currentUserAccountID, introSelected, betas); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.REPLY_IN_THREAD, - }, - { - isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.markAsUnread', - icon: 'ChatBubbleUnread', - successIcon: 'Checkmark', - shouldShow: ({type, reportAction, isUnreadChat}) => { - const isDynamicWorkflowRoutedAction = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED); - return (type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isDynamicWorkflowRoutedAction) || (type === CONST.CONTEXT_MENU_TYPES.REPORT && !isUnreadChat); - }, - onPress: (closePopover, {reportActions, reportAction, reportID, currentUserAccountID}) => { - markCommentAsUnread(reportID, reportActions, reportAction, currentUserAccountID); - if (closePopover) { - hideContextMenu(true, ReportActionComposeFocusManager.focus); - } - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.MARK_AS_UNREAD, - }, - { - isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.explain', - icon: 'Concierge', - shouldShow: ({type, reportAction, isArchivedRoom}): boolean => { - if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION || isArchivedRoom || !reportAction) { - return false; - } - - return hasReasoning(reportAction); - }, - onPress: (closePopover, {reportAction, childReport, originalReport, translate, currentUserPersonalDetails, introSelected, betas}) => { - if (!originalReport?.reportID) { - return; - } - - if (closePopover) { - hideContextMenu(false, () => { - KeyboardUtils.dismiss().then(() => { - explain(childReport, originalReport, reportAction, translate, currentUserPersonalDetails.accountID, introSelected, betas, currentUserPersonalDetails?.timezone); - }); - }); - return; - } - - explain(childReport, originalReport, reportAction, translate, currentUserPersonalDetails.accountID, introSelected, betas, currentUserPersonalDetails?.timezone); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.EXPLAIN, - }, - { - isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.markAsRead', - icon: 'Mail', - successIcon: 'Checkmark', - shouldShow: ({type, isUnreadChat}) => type === CONST.CONTEXT_MENU_TYPES.REPORT && isUnreadChat, - onPress: (closePopover, {reportID}) => { - readNewestAction(reportID, true, true); - if (closePopover) { - hideContextMenu(true, ReportActionComposeFocusManager.focus); - } - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.MARK_AS_READ, - }, - { - isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.editAction', - icon: 'Pencil', - shouldShow: ({type, reportAction, isArchivedRoom, isChronosReport, moneyRequestAction, iouTransaction}) => - type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && - (canEditReportAction(reportAction, iouTransaction) || canEditReportAction(moneyRequestAction, iouTransaction)) && - !isArchivedRoom && - !isChronosReport, - onPress: (closePopover, {reportID, reportAction, draftMessage, moneyRequestAction, introSelected, betas}) => { - if (isMoneyRequestAction(reportAction) || isMoneyRequestAction(moneyRequestAction)) { - const editExpense = () => { - const childReportID = reportAction?.childReportID; - openReport({reportID: childReportID, introSelected, betas}); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); - }; - if (closePopover) { - hideContextMenu(false, editExpense); - return; - } - editExpense(); - return; - } - const editAction = () => { - if (!draftMessage) { - saveReportActionDraft(reportID, reportAction, Parser.htmlToMarkdown(getActionHtml(reportAction))); - } else { - deleteReportActionDraft(reportID, reportAction); - } - }; - - if (closePopover) { - // Hide popover, then call editAction - hideContextMenu(false, editAction); - return; - } - - // No popover to hide, call editAction immediately - editAction(); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.EDIT_COMMENT, - }, - { - isAnonymousAction: false, - textTranslateKey: 'iou.unhold', - icon: 'Stopwatch', - shouldShow: ({type, moneyRequestReport, moneyRequestAction, moneyRequestPolicy, areHoldRequirementsMet, iouTransaction, currentUserAccountID}) => { - if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION || !areHoldRequirementsMet) { - return false; - } - const holdReportAction = getReportAction(moneyRequestAction?.childReportID, `${iouTransaction?.comment?.hold ?? ''}`); - return canHoldUnholdReportAction(moneyRequestReport, moneyRequestAction, holdReportAction, iouTransaction, moneyRequestPolicy, currentUserAccountID).canUnholdRequest; - }, - onPress: (closePopover, {moneyRequestAction, iouTransaction, isDelegateAccessRestricted, showDelegateNoAccessModal, isOffline}) => { - if (isDelegateAccessRestricted) { - hideContextMenu(false, showDelegateNoAccessModal); - return; - } - - if (closePopover) { - hideContextMenu(false, () => changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction, isOffline)); - return; - } - - // No popover to hide, call changeMoneyRequestHoldStatus immediately - changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction, isOffline); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.UNHOLD, - }, - { - isAnonymousAction: false, - textTranslateKey: 'iou.hold', - icon: 'Stopwatch', - shouldShow: ({type, moneyRequestReport, moneyRequestAction, moneyRequestPolicy, areHoldRequirementsMet, iouTransaction, currentUserAccountID}) => { - if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION || !areHoldRequirementsMet) { - return false; - } - const holdReportAction = getReportAction(moneyRequestAction?.childReportID, `${iouTransaction?.comment?.hold ?? ''}`); - return canHoldUnholdReportAction(moneyRequestReport, moneyRequestAction, holdReportAction, iouTransaction, moneyRequestPolicy, currentUserAccountID).canHoldRequest; - }, - onPress: (closePopover, {moneyRequestAction, iouTransaction, isDelegateAccessRestricted, showDelegateNoAccessModal, isOffline}) => { - if (isDelegateAccessRestricted) { - hideContextMenu(false, showDelegateNoAccessModal); - return; - } - - if (closePopover) { - hideContextMenu(false, () => changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction, isOffline)); - return; - } - - // No popover to hide, call changeMoneyRequestHoldStatus immediately - changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction, isOffline); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.HOLD, - }, - { - isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.joinThread', - icon: 'Bell', - shouldShow: ({reportAction, isArchivedRoom, isThreadReportParentAction, isHarvestReport}) => { - const childReportNotificationPreference = getChildReportNotificationPreferenceReportUtils(reportAction); - const isDeletedAction = isDeletedActionReportActionsUtils(reportAction); - const shouldDisplayThreadReplies = shouldDisplayThreadRepliesReportUtils(reportAction, isThreadReportParentAction); - const subscribed = childReportNotificationPreference !== 'hidden'; - const isWhisperAction = isWhisperActionReportActionsUtils(reportAction) || isActionableTrackExpense(reportAction); - const isExpenseReportAction = isMoneyRequestAction(reportAction) || isReportPreviewActionReportActionsUtils(reportAction); - const isTaskAction = isCreatedTaskReportAction(reportAction); - const isHarvestCreatedExpenseReportAction = isHarvestReport && isCreatedAction(reportAction); - const shouldDisableJoinThread = shouldDisableThread(reportAction, isThreadReportParentAction, isArchivedRoom); - return ( - !subscribed && - !isWhisperAction && - !isTaskAction && - !isExpenseReportAction && - !isThreadReportParentAction && - !isHarvestCreatedExpenseReportAction && - !shouldDisableJoinThread && - (shouldDisplayThreadReplies || (!isDeletedAction && !isArchivedRoom)) - ); - }, - onPress: (closePopover, {reportAction, currentUserAccountID, originalReport, introSelected, isSelfTourViewed, betas}) => { - const childReportNotificationPreference = getChildReportNotificationPreferenceReportUtils(reportAction); - if (closePopover) { - hideContextMenu(false, () => { - ReportActionComposeFocusManager.focus(); - toggleSubscribeToChildReport( - reportAction?.childReportID, - currentUserAccountID, - reportAction, - originalReport, - introSelected, - isSelfTourViewed, - betas, - childReportNotificationPreference, - ); - }); - return; - } - - ReportActionComposeFocusManager.focus(); - toggleSubscribeToChildReport( - reportAction?.childReportID, - currentUserAccountID, - reportAction, - originalReport, - introSelected, - isSelfTourViewed, - betas, - childReportNotificationPreference, - ); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.JOIN_THREAD, - }, - { - isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.leaveThread', - icon: 'Exit', - shouldShow: ({reportAction, isArchivedRoom, isThreadReportParentAction, isHarvestReport}) => { - const childReportNotificationPreference = getChildReportNotificationPreferenceReportUtils(reportAction); - const isDeletedAction = isDeletedActionReportActionsUtils(reportAction); - const shouldDisplayThreadReplies = shouldDisplayThreadRepliesReportUtils(reportAction, isThreadReportParentAction); - const subscribed = childReportNotificationPreference !== 'hidden'; - const isWhisperAction = isWhisperActionReportActionsUtils(reportAction) || isActionableTrackExpense(reportAction); - const isExpenseReportAction = isMoneyRequestAction(reportAction) || isReportPreviewActionReportActionsUtils(reportAction); - const isTaskAction = isCreatedTaskReportAction(reportAction); - const isHarvestCreatedExpenseReportAction = isHarvestReport && isCreatedAction(reportAction); - return ( - subscribed && - !isWhisperAction && - !isTaskAction && - !isExpenseReportAction && - !isThreadReportParentAction && - !isHarvestCreatedExpenseReportAction && - (shouldDisplayThreadReplies || (!isDeletedAction && !isArchivedRoom)) - ); - }, - onPress: (closePopover, {reportAction, currentUserAccountID, originalReport, introSelected, isSelfTourViewed, betas}) => { - const childReportNotificationPreference = getChildReportNotificationPreferenceReportUtils(reportAction); - if (closePopover) { - hideContextMenu(false, () => { - ReportActionComposeFocusManager.focus(); - toggleSubscribeToChildReport( - reportAction?.childReportID, - currentUserAccountID, - reportAction, - originalReport, - introSelected, - isSelfTourViewed, - betas, - childReportNotificationPreference, - ); - }); - return; - } - - ReportActionComposeFocusManager.focus(); - toggleSubscribeToChildReport( - reportAction?.childReportID, - currentUserAccountID, - reportAction, - originalReport, - introSelected, - isSelfTourViewed, - betas, - childReportNotificationPreference, - ); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.LEAVE_THREAD, - }, - { - isAnonymousAction: true, - textTranslateKey: 'reportActionContextMenu.copyURLToClipboard', - icon: 'Copy', - successTextTranslateKey: 'reportActionContextMenu.copied', - successIcon: 'Checkmark', - shouldShow: ({type}) => type === CONST.CONTEXT_MENU_TYPES.LINK, - onPress: (closePopover, {selection}) => { - Clipboard.setString(selection); - hideContextMenu(true, ReportActionComposeFocusManager.focus); - }, - getDescription: (selection) => selection, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_URL, - }, - { - isAnonymousAction: true, - textTranslateKey: 'common.copyToClipboard', - icon: 'Copy', - successTextTranslateKey: 'reportActionContextMenu.copied', - successIcon: 'Checkmark', - shouldShow: ({type}) => type === CONST.CONTEXT_MENU_TYPES.TEXT, - onPress: (closePopover, {selection}) => { - Clipboard.setString(selection); - hideContextMenu(true, ReportActionComposeFocusManager.focus); - }, - getDescription: () => undefined, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_TO_CLIPBOARD, - }, - { - isAnonymousAction: true, - textTranslateKey: 'reportActionContextMenu.copyEmailToClipboard', - icon: 'Copy', - successTextTranslateKey: 'reportActionContextMenu.copied', - successIcon: 'Checkmark', - shouldShow: ({type}) => type === CONST.CONTEXT_MENU_TYPES.EMAIL, - onPress: (closePopover, {selection}) => { - Clipboard.setString(EmailUtils.trimMailTo(selection)); - hideContextMenu(true, ReportActionComposeFocusManager.focus); - }, - getDescription: (selection) => EmailUtils.prefixMailSeparatorsWithBreakOpportunities(EmailUtils.trimMailTo(selection ?? '')), - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_EMAIL, - }, - { - isAnonymousAction: true, - textTranslateKey: 'reportActionContextMenu.copyMessage', - icon: 'Copy', - successTextTranslateKey: 'reportActionContextMenu.copied', - successIcon: 'Checkmark', - shouldShow: ({type, reportAction}) => - type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isReportActionAttachment(reportAction) && !isMessageDeleted(reportAction) && !isTripPreview(reportAction), - - // If return value is true, we switch the `text` and `icon` on - // `ContextMenuItem` with `successText` and `successIcon` which will fall back to - // the `text` and `icon` - onPress: ( - closePopover, - { - reportAction, - transaction, - selection, - report, - card, - originalReport, - isHarvestReport, - isTryNewDotNVPDismissed, - movedFromReport, - movedToReport, - childReport, - policy, - getLocalDateFromDatetime, - policyTags, - translate, - harvestReport, - currentUserPersonalDetails, - bankAccountList, - conciergeReportID, - }, - ) => { - const isReportPreviewAction = isReportPreviewActionReportActionsUtils(reportAction); - const messageHtml = getActionHtml(reportAction); - const messageText = getReportActionMessageText(reportAction); - - const isAttachment = isReportActionAttachment(reportAction); - if (!isAttachment) { - const content = selection || messageHtml; - if (isReportPreviewAction) { - const iouReportID = getIOUReportIDFromReportActionPreview(reportAction); - const displayMessage = getReportPreviewMessage(iouReportID, conciergeReportID, reportAction, undefined, undefined, undefined, undefined, undefined, true); - Clipboard.setString(displayMessage); - } else if (isTaskActionReportActionsUtils(reportAction)) { - const {text, html} = getTaskReportActionMessage(translate, reportAction); - const displayMessage = html ?? text; - setClipboardMessage(displayMessage); - } else if (isModifiedExpenseAction(reportAction)) { - const modifyExpenseMessageWithHTML = getForReportAction({ - translate, - reportAction, - policy, - movedFromReport, - movedToReport, - policyTags, - currentUserLogin: currentUserPersonalDetails?.email ?? '', - }); - // Convert HTML to markdown for clipboard copy to preserve links and formatting - const modifyExpenseMessage = Parser.htmlToMarkdown(modifyExpenseMessageWithHTML); - Clipboard.setString(modifyExpenseMessage); - } else if (isReimbursementDeQueuedOrCanceledAction(reportAction)) { - const displayMessage = getReimbursementDeQueuedOrCanceledActionMessage(translate, reportAction, report); - Clipboard.setString(displayMessage); - } else if (isMoneyRequestAction(reportAction)) { - const displayMessage = getIOUReportActionDisplayMessage(translate, reportAction, transaction, report, bankAccountList); - if (displayMessage === Parser.htmlToText(displayMessage)) { - Clipboard.setString(displayMessage); - } else { - setClipboardMessage(displayMessage); - } - } else if (isCreatedTaskReportAction(reportAction)) { - const taskPreviewMessage = getTaskCreatedMessage(translate, reportAction, childReport, true); - Clipboard.setString(taskPreviewMessage); - } else if (isMemberChangeAction(reportAction)) { - // eslint-disable-next-line @typescript-eslint/no-deprecated - const logMessage = getMemberChangeMessageFragment(translate, reportAction, getReportNameDeprecated).html ?? ''; - setClipboardMessage(logMessage); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME) { - Clipboard.setString(Str.htmlDecode(getWorkspaceNameUpdatedMessage(translate, reportAction))); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DESCRIPTION) { - setClipboardMessage(getWorkspaceDescriptionUpdatedMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY) { - Clipboard.setString(getWorkspaceCurrencyUpdateMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY) { - Clipboard.setString(getWorkspaceFrequencyUpdateMessage(translate, reportAction)); - } else if ( - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY || - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY || - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY || - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME - ) { - Clipboard.setString(getWorkspaceCategoryUpdateMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORIES) { - Clipboard.setString(getWorkspaceCategoriesUpdatedMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.IMPORT_TAGS) { - Clipboard.setString(translate('workspaceActions.importTags')); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_ALL_TAGS) { - Clipboard.setString(translate('workspaceActions.deletedAllTags')); - } else if ( - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAX || - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAX || - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAX - ) { - Clipboard.setString(getWorkspaceTaxUpdateMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_TAX_NAME) { - Clipboard.setString(getCustomTaxNameUpdateMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY_DEFAULT_TAX) { - Clipboard.setString(getCurrencyDefaultTaxUpdateMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FOREIGN_CURRENCY_DEFAULT_TAX) { - Clipboard.setString(getForeignCurrencyDefaultTaxUpdateMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST_NAME) { - Clipboard.setString(getCleanedTagName(getTagListNameUpdatedMessage(translate, reportAction))); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST) { - Clipboard.setString(getCleanedTagName(getTagListUpdatedMessage(translate, reportAction))); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST_REQUIRED) { - Clipboard.setString(getCleanedTagName(getTagListUpdatedRequiredMessage(translate, reportAction))); - } else if (isTagModificationAction(reportAction.actionName)) { - Clipboard.setString(getCleanedTagName(getWorkspaceTagUpdateMessage(translate, reportAction))); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT) { - Clipboard.setString(getWorkspaceCustomUnitUpdatedMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.IMPORT_CUSTOM_UNIT_RATES) { - Clipboard.setString(getWorkspaceCustomUnitRateImportedMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT_RATE) { - Clipboard.setString(getWorkspaceCustomUnitRateAddedMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT_RATE) { - Clipboard.setString(getWorkspaceCustomUnitRateUpdatedMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_RATE) { - Clipboard.setString(getWorkspaceCustomUnitRateDeletedMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT_SUB_RATE) { - Clipboard.setString(getWorkspaceCustomUnitSubRateUpdatedMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_SUB_RATE) { - Clipboard.setString(getWorkspaceCustomUnitSubRateDeletedMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_REPORT_FIELD) { - Clipboard.setString(getWorkspaceReportFieldAddMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REPORT_FIELD) { - Clipboard.setString(getWorkspaceReportFieldUpdateMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_REPORT_FIELD) { - Clipboard.setString(getWorkspaceReportFieldDeleteMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FIELD) { - setClipboardMessage(getWorkspaceUpdateFieldMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FEATURE_ENABLED) { - Clipboard.setString(getWorkspaceFeatureEnabledMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_IS_ATTENDEE_TRACKING_ENABLED) { - Clipboard.setString(getWorkspaceAttendeeTrackingUpdateMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_APPROVER) { - Clipboard.setString(getDefaultApproverUpdateMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_SUBMITS_TO) { - Clipboard.setString(getSubmitsToUpdateMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FORWARDS_TO) { - Clipboard.setString(getForwardsToUpdateMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_PAY_APPROVED_REPORTS_ENABLED) { - Clipboard.setString(getAutoPayApprovedReportsEnabledMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT) { - Clipboard.setString(getAutoReimbursementMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_NAME) { - Clipboard.setString(getInvoiceCompanyNameUpdateMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_WEBSITE) { - Clipboard.setString(getInvoiceCompanyWebsiteUpdateMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSER) { - Clipboard.setString(getReimburserUpdateMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_ENABLED) { - Clipboard.setString(getWorkspaceReimbursementUpdateMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_ACH_ACCOUNT) { - Clipboard.setString(getUpdateACHAccountMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_ADDRESS) { - Clipboard.setString(getCompanyAddressUpdateMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT) { - Clipboard.setString(getPolicyChangeLogMaxExpenseAmountNoReceiptMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT) { - Clipboard.setString(getPolicyChangeLogMaxExpenseAmountMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AGE) { - Clipboard.setString(getPolicyChangeLogMaxExpenseAgeMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE) { - Clipboard.setString(getPolicyChangeLogDefaultBillableMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_REIMBURSABLE) { - Clipboard.setString(getPolicyChangeLogDefaultReimbursableMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED) { - Clipboard.setString(getPolicyChangeLogDefaultTitleEnforcedMessage(translate, reportAction)); - } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OWNERSHIP) { - setClipboardMessage(Parser.htmlToText(getUpdatedOwnershipMessage(translate, reportAction, policy) ?? '')); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { - setClipboardMessage(getUnreportedTransactionMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) { - Clipboard.setString(getMarkedReimbursedMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REIMBURSED)) { - Clipboard.setString(getReportActionMessageFragments(translate, reportAction).at(0)?.text ?? ''); - } else if (isReimbursementQueuedAction(reportAction)) { - Clipboard.setString( - getReimbursementQueuedActionMessage({reportAction, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils, report, shouldUseShortDisplayName: false}), - ); - } else if (isActionableMentionWhisper(reportAction)) { - const mentionWhisperMessage = getActionableMentionWhisperMessage(translate, reportAction); - setClipboardMessage(mentionWhisperMessage); - } else if (isActionableTrackExpense(reportAction)) { - setClipboardMessage(CONST.ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE); - } else if (isRenamedAction(reportAction)) { - setClipboardMessage(getRenamedAction(translate, reportAction, isExpenseReport(report))); - } else if ( - isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) || - isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED) || - isMarkAsClosedAction(reportAction) - ) { - const harvesting = !isMarkAsClosedAction(reportAction) ? (getOriginalMessage(reportAction)?.harvesting ?? false) : false; - if (harvesting) { - setClipboardMessage(translate('iou.automaticallySubmitted')); - } else { - Clipboard.setString(translate('iou.submitted', getOriginalMessage(reportAction)?.message)); - } - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.APPROVED)) { - const {automaticAction} = getOriginalMessage(reportAction) ?? {}; - if (automaticAction) { - setClipboardMessage(translate('iou.automaticallyApproved')); - } else { - Clipboard.setString(translate('iou.approvedMessage')); - } - } else if (isUnapprovedAction(reportAction)) { - Clipboard.setString(translate('iou.unapproved')); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED)) { - const {automaticAction} = getOriginalMessage(reportAction) ?? {}; - if (automaticAction) { - setClipboardMessage(translate('iou.automaticallyForwarded')); - } else { - Clipboard.setString(translate('iou.forwarded')); - } - } else if (isRejectedAction(reportAction)) { - Clipboard.setString(translate('iou.rejectedThisReport')); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_UPGRADE) { - const displayMessage = translate('workspaceActions.upgradedWorkspace'); - Clipboard.setString(displayMessage); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_FORCE_UPGRADE) { - const displayMessage = Parser.htmlToText(translate('workspaceActions.forcedCorporateUpgrade')); - Clipboard.setString(displayMessage); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.TEAM_DOWNGRADE) { - Clipboard.setString(translate('workspaceActions.downgradedWorkspace')); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) { - Clipboard.setString(translate('iou.heldExpense')); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { - Clipboard.setString(translate('iou.unheldExpense')); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REJECTEDTRANSACTION_THREAD) { - Clipboard.setString(translate('iou.reject.reportActions.rejectedExpense')); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REJECTED_TRANSACTION_MARKASRESOLVED) { - Clipboard.setString(translate('iou.reject.reportActions.markedAsResolved')); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RETRACTED) { - Clipboard.setString(translate('iou.retracted')); - } else if (isOldDotReportAction(reportAction)) { - const oldDotActionMessage = getMessageOfOldDotReportAction(translate, reportAction); - Clipboard.setString(oldDotActionMessage); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION) { - const originalMessage = getOriginalMessage(reportAction) as ReportAction['originalMessage']; - Clipboard.setString(getDismissedViolationMessageText(translate, originalMessage)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES) { - Clipboard.setString(translate('violations.resolvedDuplicates')); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) { - setClipboardMessage(getExportIntegrationMessageHTML(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { - setClipboardMessage(getUpdateRoomDescriptionMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_AVATAR) { - setClipboardMessage(getRoomAvatarUpdatedMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EMPLOYEE) { - setClipboardMessage(getPolicyChangeLogAddEmployeeMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE) { - setClipboardMessage(getPolicyChangeLogUpdateEmployee(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE) { - setClipboardMessage(getPolicyChangeLogDeleteMemberMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION) { - setClipboardMessage(getDeletedTransactionMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REOPENED) { - setClipboardMessage(translate('iou.reopened')); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED)) { - setClipboardMessage(getIntegrationSyncFailedMessage(translate, reportAction, report?.policyID, isTryNewDotNVPDismissed)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN)) { - setClipboardMessage(getCompanyCardConnectionBrokenMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE)) { - setClipboardMessage(getPlaidBalanceFailureMessage(translate, reportAction)); - } else if (isCardIssuedAction(reportAction)) { - const shouldNavigateToCardDetails = isPolicyAdmin(policy, currentUserPersonalDetails.login); - setClipboardMessage( - getCardIssuedMessage({reportAction, shouldRenderHTML: true, shouldNavigateToCardDetails, policyID: report?.policyID, expensifyCard: card, translate}), - ); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_INTEGRATION)) { - setClipboardMessage(getAddedConnectionMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION)) { - setClipboardMessage(getRemovedConnectionMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CARD_FEED)) { - setClipboardMessage(getAddedCardFeedMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CARD_FEED)) { - setClipboardMessage(getRemovedCardFeedMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.RENAME_CARD_FEED)) { - setClipboardMessage(getRenamedCardFeedMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ASSIGN_COMPANY_CARD)) { - setClipboardMessage(getAssignedCompanyCardMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UNASSIGN_COMPANY_CARD)) { - setClipboardMessage(getUnassignedCompanyCardMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CARD_FEED_LIABILITY)) { - setClipboardMessage(getUpdatedCardFeedLiabilityMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CARD_FEED_STATEMENT_PERIOD)) { - setClipboardMessage(getUpdatedCardFeedStatementPeriodMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.TRAVEL_UPDATE)) { - setClipboardMessage(getTravelUpdateMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUDIT_RATE)) { - setClipboardMessage(getUpdatedAuditRateMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_APPROVER_RULE)) { - setClipboardMessage(getAddedApprovalRuleMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_APPROVER_RULE)) { - setClipboardMessage(getDeletedApprovalRuleMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_APPROVER_RULE)) { - setClipboardMessage(getUpdatedApprovalRuleMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MANUAL_APPROVAL_THRESHOLD)) { - setClipboardMessage(getUpdatedManualApprovalThresholdMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_BUDGET)) { - setClipboardMessage(getAddedBudgetMessage(translate, reportAction, policy)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_BUDGET)) { - setClipboardMessage(getUpdatedBudgetMessage(translate, reportAction, policy)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_BUDGET)) { - setClipboardMessage(getDeletedBudgetMessage(translate, reportAction, policy)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TIME_ENABLED)) { - setClipboardMessage(getUpdatedTimeEnabledMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TIME_RATE)) { - setClipboardMessage(getUpdatedTimeRateMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_PROHIBITED_EXPENSES)) { - setClipboardMessage(getUpdatedProhibitedExpensesMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_CHOICE)) { - setClipboardMessage(getUpdatedReimbursementChoiceMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_AUTO_JOIN)) { - setClipboardMessage(getSetAutoJoinMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE)) { - setClipboardMessage(getUpdatedDefaultTitleMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_HARVESTING)) { - setClipboardMessage(getUpdatedAutoHarvestingMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INDIVIDUAL_BUDGET_NOTIFICATION)) { - setClipboardMessage(getUpdatedIndividualBudgetNotificationMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SHARED_BUDGET_NOTIFICATION)) { - setClipboardMessage(getUpdatedSharedBudgetNotificationMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.TAKE_CONTROL) || isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REROUTE)) { - setClipboardMessage(getChangedApproverActionMessage(translate, reportAction)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION)) { - setClipboardMessage(getMovedTransactionMessage(translate, reportAction, conciergeReportID)); - } else if (isMovedAction(reportAction)) { - setClipboardMessage(getMovedActionMessage(translate, reportAction, originalReport)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT)) { - setClipboardMessage(getActionableCardFraudAlertMessage(translate, reportAction, getLocalDateFromDatetime)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL)) { - setClipboardMessage(getActionableCard3DSTransactionApprovalMessage(translate, reportAction)); - } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY) { - const displayMessage = getPolicyChangeMessage(translate, reportAction); - Clipboard.setString(displayMessage); - } else if (isActionableJoinRequest(reportAction)) { - const displayMessage = getJoinRequestMessage(translate, policy, reportAction); - Clipboard.setString(displayMessage); - } else if ( - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.LEAVE_ROOM || - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM - ) { - Clipboard.setString(translate('report.actions.type.leftTheChat')); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED)) { - setClipboardMessage(getDynamicExternalWorkflowRoutedMessage(reportAction, translate)); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CREATED) && isHarvestReport) { - const harvestReportName = getReportName(harvestReport); - const displayMessage = getHarvestCreatedExpenseReportMessage(harvestReport?.reportID, harvestReportName, translate); - setClipboardMessage(displayMessage); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CREATED_REPORT_FOR_UNAPPROVED_TRANSACTIONS)) { - const {originalID} = getOriginalMessage(reportAction) ?? {}; - const originalReportOfUnapprovedTransaction = getReportOrDraftReport(originalID); - const reportName = getReportName(originalReportOfUnapprovedTransaction); - const displayMessage = getCreatedReportForUnapprovedTransactionsMessage( - originalID, - reportName, - isOriginalReportDeleted(reportAction, originalReportOfUnapprovedTransaction), - translate, - ); - setClipboardMessage(displayMessage); - } else if (isDynamicExternalWorkflowSubmitFailedAction(reportAction)) { - setClipboardMessage(getDynamicExternalWorkflowSubmitFailedActionMessage(translate, reportAction)); - } else if (isDynamicExternalWorkflowApproveFailedAction(reportAction)) { - setClipboardMessage(getDynamicExternalWorkflowApproveFailedActionMessage(translate, reportAction)); - } else if (content) { - setClipboardMessage( - content.replaceAll(/()(.*?)(<\/mention-user>)/gi, (match, openTag: string, innerContent: string, closeTag: string): string => { - const modifiedContent = Str.removeSMSDomain(innerContent) || ''; - return openTag + modifiedContent + closeTag || ''; - }), - ); - } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED)) { - setClipboardMessage(getSettlementAccountLockedMessage(translate, reportAction)); - } else if (messageText) { - Clipboard.setString(messageText); - } - } - - if (closePopover) { - hideContextMenu(true, ReportActionComposeFocusManager.focus); - } - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_MESSAGE, - }, - { - isAnonymousAction: true, - textTranslateKey: 'reportActionContextMenu.copyLink', - icon: 'LinkCopy', - successIcon: 'Checkmark', - successTextTranslateKey: 'reportActionContextMenu.copied', - shouldShow: ({type, reportAction, menuTarget}) => { - const isAttachment = isReportActionAttachment(reportAction); - - // Only hide the copy link menu item when context menu is opened over img element. - const isAttachmentTarget = menuTarget?.current && 'tagName' in menuTarget.current && menuTarget?.current.tagName === 'IMG' && isAttachment; - const isDynamicWorkflowRoutedAction = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED); - return type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget && !isMessageDeleted(reportAction) && !isDynamicWorkflowRoutedAction; - }, - onPress: (closePopover, {reportAction, originalReportID}) => { - getEnvironmentURL().then((environmentURL) => { - const reportActionID = reportAction?.reportActionID; - Clipboard.setString(`${environmentURL}/r/${originalReportID}/${reportActionID}`); - }); - hideContextMenu(true, ReportActionComposeFocusManager.focus); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_LINK, - }, - { - isAnonymousAction: false, - textTranslateKey: 'common.pin', - icon: 'Pin', - shouldShow: ({type, isPinnedChat}) => type === CONST.CONTEXT_MENU_TYPES.REPORT && !isPinnedChat, - onPress: (closePopover, {reportID}) => { - togglePinnedState(reportID, false); - if (closePopover) { - hideContextMenu(false, ReportActionComposeFocusManager.focus); - } - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.PIN, - }, - { - isAnonymousAction: false, - textTranslateKey: 'common.unPin', - icon: 'Pin', - shouldShow: ({type, isPinnedChat}) => type === CONST.CONTEXT_MENU_TYPES.REPORT && isPinnedChat, - onPress: (closePopover, {reportID}) => { - togglePinnedState(reportID, true); - if (closePopover) { - hideContextMenu(false, ReportActionComposeFocusManager.focus); - } - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.UNPIN, - }, - { - isAnonymousAction: false, - textTranslateKey: 'reportActionContextMenu.flagAsOffensive', - icon: 'Flag', - shouldShow: ({type, reportAction, isArchivedRoom, isChronosReport, reportID}) => - type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && - canFlagReportAction(reportAction, reportID) && - !isArchivedRoom && - !isChronosReport && - reportAction?.actorAccountID !== CONST.ACCOUNT_ID.CONCIERGE, - onPress: (closePopover, {reportAction, originalReportID}) => { - if (!originalReportID) { - return; - } - - if (closePopover) { - hideContextMenu(false, () => { - KeyboardUtils.dismiss().then(() => { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.FLAG_COMMENT.getRoute(originalReportID, reportAction.reportActionID))); - }); - }); - return; - } - - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.FLAG_COMMENT.getRoute(originalReportID, reportAction.reportActionID))); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.FLAG_AS_OFFENSIVE, - }, - { - isAnonymousAction: true, - textTranslateKey: 'common.download', - icon: 'Download', - successTextTranslateKey: 'common.download', - successIcon: 'Download', - shouldShow: ({reportAction, isOffline}) => { - const isAttachment = isReportActionAttachment(reportAction); - const html = getActionHtml(reportAction); - const isUploading = html.includes(CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE); - return isAttachment && !isUploading && !!reportAction?.reportActionID && !isMessageDeleted(reportAction) && !isOffline; - }, - onPress: (closePopover, {reportAction, translate, encryptedAuthToken}) => { - const html = getActionHtml(reportAction); - const {originalFileName, sourceURL} = getAttachmentDetails(html); - const sourceURLWithAuth = addEncryptedAuthTokenToURL(sourceURL ?? '', encryptedAuthToken); - const sourceID = (sourceURL?.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_SOURCE_ID) ?? [])[1]; - setDownload(sourceID, true); - const anchorRegex = CONST.REGEX_LINK_IN_ANCHOR; - const isAnchorTag = anchorRegex.test(html); - fileDownload(translate, sourceURLWithAuth, originalFileName ?? '', '', isAnchorTag && isMobileSafari()).then(() => setDownload(sourceID, false)); - if (closePopover) { - hideContextMenu(true, ReportActionComposeFocusManager.focus); - } - }, - getDescription: () => {}, - shouldDisable: (download) => download?.isDownloading ?? false, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.DOWNLOAD, - }, - { - isAnonymousAction: true, - textTranslateKey: 'reportActionContextMenu.copyOnyxData', - icon: 'Copy', - successTextTranslateKey: 'reportActionContextMenu.copied', - successIcon: 'Checkmark', - shouldShow: ({type, isProduction}) => type === CONST.CONTEXT_MENU_TYPES.REPORT && !isProduction, - onPress: (closePopover, {report}) => { - Clipboard.setString(JSON.stringify(report, null, 4)); - hideContextMenu(true, ReportActionComposeFocusManager.focus); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_ONYX_DATA, - }, - { - isAnonymousAction: true, - textTranslateKey: 'debug.debug', - icon: 'Bug', - shouldShow: ({type, isDebugModeEnabled}) => [CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, CONST.CONTEXT_MENU_TYPES.REPORT].some((value) => value === type) && !!isDebugModeEnabled, - onPress: (closePopover, {reportID, reportAction}) => { - if (reportAction) { - Navigation.navigate(ROUTES.DEBUG_REPORT_ACTION.getRoute(reportID, reportAction.reportActionID)); - } else { - Navigation.navigate(ROUTES.DEBUG_REPORT.getRoute(reportID)); - } - hideContextMenu(false, ReportActionComposeFocusManager.focus); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.DEBUG, - }, - { - isAnonymousAction: false, - textTranslateKey: 'common.delete', - icon: 'Trashcan', - shouldShow: ({type, reportAction, isArchivedRoom, isChronosReport, reportID: reportIDParam, moneyRequestAction, iouTransaction, transactions, childReportActions}) => { - // Until deleting parent threads is supported in FE, we will prevent the user from deleting a thread parent - let reportID = reportIDParam; - - if (isMoneyRequestAction(moneyRequestAction)) { - reportID = getOriginalMessage(moneyRequestAction)?.IOUReportID; - } else if (isReportPreviewActionReportActionsUtils(reportAction)) { - reportID = reportAction?.childReportID; - } - return ( - !!reportIDParam && - type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && - canDeleteReportAction(moneyRequestAction ?? reportAction, reportID, iouTransaction, transactions, childReportActions) && - !isArchivedRoom && - !isChronosReport && - !isMessageDeleted(reportAction) - ); - }, - onPress: (closePopover, {reportID: reportIDParam, reportAction, moneyRequestAction}) => { - const iouReportID = isMoneyRequestAction(moneyRequestAction) ? getOriginalMessage(moneyRequestAction)?.IOUReportID : undefined; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const reportID = iouReportID && Number(iouReportID) !== 0 ? iouReportID : reportIDParam; - if (closePopover) { - // Hide popover, then call showDeleteConfirmModal - hideContextMenu(false, () => showDeleteModal(reportID, moneyRequestAction ?? reportAction)); - return; - } - - // No popover to hide, call showDeleteConfirmModal immediately - showDeleteModal(reportID, moneyRequestAction ?? reportAction); - }, - getDescription: () => {}, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.DELETE, - }, - { - isAnonymousAction: true, - textTranslateKey: 'reportActionContextMenu.menu', - icon: 'ThreeDots', - shouldShow: ({isMini}) => isMini, - onPress: (closePopover, {openOverflowMenu, event, openContextMenu, anchorRef}) => { - openOverflowMenu(event as GestureResponderEvent | MouseEvent, anchorRef ?? {current: null}); - openContextMenu(); - }, - getDescription: () => {}, - shouldPreventDefaultFocusOnPress: false, - sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.MENU, - }, -]; - -const restrictedReadOnlyActions = new Set([ - 'reportActionContextMenu.replyInThread', - 'reportActionContextMenu.editAction', - 'reportActionContextMenu.joinThread', - 'common.delete', -]); - -const RestrictedReadOnlyContextMenuActions: ContextMenuAction[] = ContextMenuActions.filter( - (action) => 'textTranslateKey' in action && restrictedReadOnlyActions.has(action.textTranslateKey), -); - -export {RestrictedReadOnlyContextMenuActions}; -export default ContextMenuActions; -export type {ContextMenuActionPayload, ContextMenuAction}; diff --git a/src/pages/inbox/report/ContextMenu/MiniContextMenuProvider.tsx b/src/pages/inbox/report/ContextMenu/MiniContextMenuProvider.tsx new file mode 100644 index 000000000000..ee045391c17d --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/MiniContextMenuProvider.tsx @@ -0,0 +1,189 @@ +import type {ReactNode, RefObject} from 'react'; +import React, {createContext, useContext, useEffect, useRef, useState} from 'react'; +import type {ContextMenuAnchor} from './ReportActionContextMenu'; + +/** + * Grace period between a hide being requested (e.g. row mouseleave) and the menu actually hiding. + * Gives the menu's own Hoverable a window to cancel the hide when the cursor lands on it, enabling + * seamless row → menu hover transitions without the menu flickering off. + */ +const HIDE_GRACE_PERIOD_MS = 80; + +type RowMeasurements = { + top: number; + height: number; + right: number; +}; + +type MiniContextMenuParams = { + reportID: string | undefined; + reportActionID: string; + originalReportID: string | undefined; + anchor: RefObject; + displayAsGroup: boolean; + draftMessage: string | undefined; + checkIfContextMenuActive: () => void; + setIsEmojiPickerActive: (state: boolean) => void; + rowMeasurements: RowMeasurements; +}; + +type ShowMiniContextMenuParams = MiniContextMenuParams & { + onMenuHide?: () => void; +}; + +type MiniContextMenuState = MiniContextMenuParams & { + isVisible: boolean; +}; + +type MiniContextMenuActions = { + /** Display the mini context menu with the given parameters. */ + showMiniContextMenu: (params: ShowMiniContextMenuParams) => void; + + /** + * Schedule hiding the mini context menu after a short grace period. The hide is cancellable + * by a subsequent `showMiniContextMenu` or `keepOpen` call — this is what allows the cursor + * to transition from the hovered row onto the menu itself without the menu flickering away. + * While `keepOpen` is active the hide intent is deferred until `release` is called. + */ + hideMiniContextMenu: () => void; + + /** + * Hide the mini menu without invoking `onMenuHide` (e.g. when opening the full popover context menu while the pointer stays over the row). + * Clears the keep-open guard so the menu actually hides. + */ + hideMiniContextMenuWithoutNotification: () => void; + + /** Lock the menu open so that `hideMiniContextMenu` calls are deferred until `release` is called. Use when a sub-interaction (overflow menu, emoji picker) needs the menu to stay visible. Also used by the menu's own Hoverable to prevent hide during row-to-menu hover transitions. */ + keepOpen: () => void; + + /** Unlock the menu after `keepOpen`. If a hide was deferred while locked, it executes immediately. */ + release: () => void; + + /** Ref to the mini menu's container element, used by PureReportActionItem to bridge Tab focus from the row to the Portal-rendered menu. */ + menuContainerRef: RefObject; +}; + +const MiniContextMenuActionsContext = createContext({ + showMiniContextMenu: () => {}, + hideMiniContextMenu: () => {}, + hideMiniContextMenuWithoutNotification: () => {}, + keepOpen: () => {}, + release: () => {}, + menuContainerRef: {current: null}, +}); + +const MiniContextMenuStateContext = createContext(null); + +type MiniContextMenuProviderProps = { + children: ReactNode; +}; + +function MiniContextMenuProvider({children}: MiniContextMenuProviderProps) { + const [state, setState] = useState(null); + // Explicit lock for sub-interactions that must keep the menu pinned (overflow popover, + // emoji picker, right-click popover). Unrelated to the grace-period hide timer below. + const shouldKeepOpenRef = useRef(false); + // Set when a hide was requested while locked; drained when `release()` is called. + const pendingHideRef = useRef(false); + const hideTimeoutRef = useRef | null>(null); + const onMenuHideRef = useRef<(() => void) | null>(null); + const activeReportActionIDRef = useRef(undefined); + const menuContainerRef = useRef(null); + + const [actions] = useState(() => { + const cancelScheduledHide = () => { + if (!hideTimeoutRef.current) { + return; + } + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + }; + + const performHide = () => { + hideTimeoutRef.current = null; + setState((prev) => (prev ? {...prev, isVisible: false} : null)); + onMenuHideRef.current?.(); + onMenuHideRef.current = null; + activeReportActionIDRef.current = undefined; + }; + + const scheduleHide = () => { + if (shouldKeepOpenRef.current) { + pendingHideRef.current = true; + return; + } + if (hideTimeoutRef.current) { + return; + } + hideTimeoutRef.current = setTimeout(performHide, HIDE_GRACE_PERIOD_MS); + }; + + return { + showMiniContextMenu: (params: ShowMiniContextMenuParams) => { + cancelScheduledHide(); + pendingHideRef.current = false; + const isSameRow = params.reportActionID === activeReportActionIDRef.current; + if (!isSameRow) { + onMenuHideRef.current?.(); + } + activeReportActionIDRef.current = params.reportActionID; + const {onMenuHide, ...stateParams} = params; + onMenuHideRef.current = onMenuHide ?? null; + setState({...stateParams, isVisible: true}); + }, + hideMiniContextMenu: () => { + scheduleHide(); + }, + hideMiniContextMenuWithoutNotification: () => { + cancelScheduledHide(); + shouldKeepOpenRef.current = false; + pendingHideRef.current = false; + setState((prev) => (prev ? {...prev, isVisible: false} : null)); + onMenuHideRef.current = null; + activeReportActionIDRef.current = undefined; + }, + keepOpen: () => { + shouldKeepOpenRef.current = true; + cancelScheduledHide(); + pendingHideRef.current = false; + }, + release: () => { + shouldKeepOpenRef.current = false; + if (!pendingHideRef.current) { + return; + } + pendingHideRef.current = false; + performHide(); + }, + menuContainerRef, + }; + }); + + useEffect( + () => () => { + if (!hideTimeoutRef.current) { + return; + } + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + }, + [], + ); + + return ( + + {children} + + ); +} + +function useMiniContextMenuActions(): MiniContextMenuActions { + return useContext(MiniContextMenuActionsContext); +} + +function useMiniContextMenuState(): MiniContextMenuState | null { + return useContext(MiniContextMenuStateContext); +} + +export {MiniContextMenuProvider, useMiniContextMenuActions, useMiniContextMenuState}; +export type {MiniContextMenuParams, ShowMiniContextMenuParams, MiniContextMenuState, RowMeasurements, MiniContextMenuActions}; diff --git a/src/pages/inbox/report/ContextMenu/MiniReportActionContextMenu/index.native.tsx b/src/pages/inbox/report/ContextMenu/MiniReportActionContextMenu/index.native.tsx index 7be6a850d51b..9304b28c1140 100644 --- a/src/pages/inbox/report/ContextMenu/MiniReportActionContextMenu/index.native.tsx +++ b/src/pages/inbox/report/ContextMenu/MiniReportActionContextMenu/index.native.tsx @@ -1,4 +1,2 @@ -import type MiniReportActionContextMenuProps from './types'; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export default (props: MiniReportActionContextMenuProps) => null; +// Mini context menu only renders on web +export default () => null; diff --git a/src/pages/inbox/report/ContextMenu/MiniReportActionContextMenu/index.tsx b/src/pages/inbox/report/ContextMenu/MiniReportActionContextMenu/index.tsx index 8c5ea5ad8581..31b6dfc0bd62 100644 --- a/src/pages/inbox/report/ContextMenu/MiniReportActionContextMenu/index.tsx +++ b/src/pages/inbox/report/ContextMenu/MiniReportActionContextMenu/index.tsx @@ -1,24 +1,587 @@ -import React from 'react'; -import {View} from 'react-native'; +import {Portal} from '@gorhom/portal'; +import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import type {RefObject} from 'react'; +import {StyleSheet, View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import * as ActionSheetAwareScrollView from '@components/ActionSheetAwareScrollView'; +import Hoverable from '@components/Hoverable'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import MiniQuickEmojiReactions from '@components/Reactions/MiniQuickEmojiReactions'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; -import BaseReportActionContextMenu from '@pages/inbox/report/ContextMenu/BaseReportActionContextMenu'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {ACTION_IDS} from '@pages/inbox/report/ContextMenu/actions/actionConfig'; +import {shouldShowCopyLinkAction} from '@pages/inbox/report/ContextMenu/actions/CopyLinkAction/copyLinkAction'; +import MiniCopyLinkItem from '@pages/inbox/report/ContextMenu/actions/CopyLinkAction/MiniCopyLinkItem'; +import {shouldShowCopyMessageAction} from '@pages/inbox/report/ContextMenu/actions/CopyMessageAction/copyMessageAction'; +import MiniCopyMessageItem from '@pages/inbox/report/ContextMenu/actions/CopyMessageAction/MiniCopyMessageItem'; +import {shouldShowDeleteAction} from '@pages/inbox/report/ContextMenu/actions/DeleteAction/deleteAction'; +import MiniDeleteItem from '@pages/inbox/report/ContextMenu/actions/DeleteAction/MiniDeleteItem'; +import {shouldShowDownloadAction} from '@pages/inbox/report/ContextMenu/actions/DownloadAction/downloadAction'; +import MiniDownloadItem from '@pages/inbox/report/ContextMenu/actions/DownloadAction/MiniDownloadItem'; +import {shouldShowEditAction} from '@pages/inbox/report/ContextMenu/actions/EditAction/editAction'; +import MiniEditItem from '@pages/inbox/report/ContextMenu/actions/EditAction/MiniEditItem'; +import createEmojiReactionData, {shouldShowEmojiReaction} from '@pages/inbox/report/ContextMenu/actions/emojiReactionAction'; +import {shouldShowExplainAction} from '@pages/inbox/report/ContextMenu/actions/ExplainAction/explainAction'; +import MiniExplainItem from '@pages/inbox/report/ContextMenu/actions/ExplainAction/MiniExplainItem'; +import {shouldShowFlagAsOffensiveAction} from '@pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/flagAsOffensiveAction'; +import MiniFlagAsOffensiveItem from '@pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/MiniFlagAsOffensiveItem'; +import {shouldShowHoldAction} from '@pages/inbox/report/ContextMenu/actions/HoldAction/holdAction'; +import MiniHoldItem from '@pages/inbox/report/ContextMenu/actions/HoldAction/MiniHoldItem'; +import {shouldShowJoinThreadAction} from '@pages/inbox/report/ContextMenu/actions/JoinThreadAction/joinThreadAction'; +import MiniJoinThreadItem from '@pages/inbox/report/ContextMenu/actions/JoinThreadAction/MiniJoinThreadItem'; +import {shouldShowLeaveThreadAction} from '@pages/inbox/report/ContextMenu/actions/LeaveThreadAction/leaveThreadAction'; +import MiniLeaveThreadItem from '@pages/inbox/report/ContextMenu/actions/LeaveThreadAction/MiniLeaveThreadItem'; +import {shouldShowMarkAsUnreadForReportAction} from '@pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/markAsUnreadAction'; +import MiniMarkAsUnreadItem from '@pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/MiniMarkAsUnreadItem'; +import MiniReplyInThreadItem from '@pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/MiniReplyInThreadItem'; +import {shouldShowReplyInThreadAction} from '@pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/replyInThreadAction'; +import MiniUnholdItem from '@pages/inbox/report/ContextMenu/actions/UnholdAction/MiniUnholdItem'; +import {shouldShowUnholdAction} from '@pages/inbox/report/ContextMenu/actions/UnholdAction/unholdAction'; +import {useMiniContextMenuActions, useMiniContextMenuState} from '@pages/inbox/report/ContextMenu/MiniContextMenuProvider'; +import type {ContextMenuAnchor} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import useReportActionContextMenuData from '@pages/inbox/report/ContextMenu/useReportActionContextMenuData'; import CONST from '@src/CONST'; -import type MiniReportActionContextMenuProps from './types'; +import type {BankAccountList} from '@src/types/onyx'; -function MiniReportActionContextMenu({displayAsGroup = false, ...rest}: MiniReportActionContextMenuProps) { +function MiniReportActionContextMenu() { + const { + isVisible = false, + rowMeasurements, + displayAsGroup = false, + reportID, + reportActionID, + originalReportID, + draftMessage = '', + anchor, + checkIfContextMenuActive, + setIsEmojiPickerActive, + } = useMiniContextMenuState() ?? {}; + const {hideMiniContextMenu, keepOpen, release, menuContainerRef} = useMiniContextMenuActions(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + ActionSheetAwareScrollView.useActionSheetAwareScrollViewActions(); + + const overflowIcons = useMemoizedLazyExpensifyIcons(['ThreeDots'] as const); + const threeDotRef = useRef(null); + const overlayRef = useRef(null); + const localMenuContainerRef = useRef(null); + // Tracked as state (not only a ref) because the Portal attaches this View asynchronously; + // state causes the dependent effect to re-run once React commits the Portal content. + const [menuContainerEl, setMenuContainerEl] = useState(null); + const menuContainerCallbackRef = useCallback( + (node: View | null) => { + const el = node as unknown as HTMLElement | null; + localMenuContainerRef.current = el; + setMenuContainerEl(el); + // eslint-disable-next-line no-param-reassign + menuContainerRef.current = el; + }, + [menuContainerRef], + ); + const [containerRect, setContainerRect] = useState(null); + + const overlayCallbackRef = useCallback((node: View | null) => { + overlayRef.current = node; + const el = node as unknown as HTMLElement | null; + if (el) { + setContainerRect(el.getBoundingClientRect()); + } + }, []); + + useLayoutEffect(() => { + const el = overlayRef.current as unknown as HTMLElement | null; + if (!el) { + return; + } + setContainerRect(el.getBoundingClientRect()); + }, [isVisible, rowMeasurements]); + + const position = + isVisible && rowMeasurements && containerRect + ? { + top: rowMeasurements.top - containerRect.top + (displayAsGroup ? -32 : -16), + right: containerRect.right - rowMeasurements.right + 16, + } + : null; + + useEffect(() => { + if (!menuContainerEl) { + return; + } + + const onBlurCapture = (e: FocusEvent) => { + if (e.relatedTarget && menuContainerEl.contains(e.relatedTarget as Node)) { + return; + } + hideMiniContextMenu(); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab' || !e.shiftKey) { + return; + } + const anchorEl = anchor?.current as unknown as HTMLElement | null; + if (!anchorEl) { + return; + } + e.preventDefault(); + anchorEl.focus(); + }; + + menuContainerEl.addEventListener('blur', onBlurCapture, true); + menuContainerEl.addEventListener('keydown', onKeyDown); + return () => { + menuContainerEl.removeEventListener('blur', onBlurCapture, true); + menuContainerEl.removeEventListener('keydown', onKeyDown); + }; + // Depending on the ref object rather than anchor?.current avoids accessing + // refs during render (required for React Compiler compliance); the ref identity is stable. + // eslint-disable-next-line rulesdir/prefer-narrow-hook-dependencies + }, [menuContainerEl, hideMiniContextMenu, anchor]); + + useEffect(() => { + if (!isVisible) { + return; + } + const onScroll = (event: Event) => { + // Ignore scrolls that originate from inside the anchored row itself, e.g. the + // horizontal carousel on a report preview. Those don't move the row, so the + // menu is still correctly positioned and shouldn't flicker away. + const anchorEl = anchor?.current as unknown as HTMLElement | null; + const target = event.target as Node | null; + if (anchorEl && target && anchorEl.contains(target)) { + return; + } + release(); + hideMiniContextMenu(); + }; + window.addEventListener('scroll', onScroll, true); + return () => { + window.removeEventListener('scroll', onScroll, true); + }; + // Depending on the ref object rather than anchor?.current avoids accessing + // refs during render (required for React Compiler compliance); the ref identity is stable. + // eslint-disable-next-line rulesdir/prefer-narrow-hook-dependencies + }, [isVisible, release, hideMiniContextMenu, anchor]); + + useEffect(() => { + const el = localMenuContainerRef.current; + if (!el) { + return; + } + el.dataset.selectionScraperHiddenElement = String(isVisible); + }, [menuContainerEl, isVisible]); + + const { + report, + reportAction, + reportActions: reportActionsMap, + originalReport, + childReport, + childReportActions, + policy, + policyTags, + moneyRequestAction, + moneyRequestReport, + moneyRequestPolicy, + iouTransaction, + transaction, + bankAccountList, + card, + conciergeReportID, + currentUserPersonalDetails, + encryptedAuthToken, + isArchivedRoom, + isChronosReport, + isThreadReportParentAction, + isOffline, + isHarvestReport, + isTryNewDotNVPDismissed, + isDelegateAccessRestricted, + areHoldRequirementsMet, + transactions, + introSelected, + isSelfTourViewed, + betas, + movedFromReport, + movedToReport, + harvestReport, + disabledActionIDs, + showDelegateNoAccessModal, + getLocalDateFromDatetime, + reportID: resolvedReportID, + originalReportID: resolvedOriginalReportID, + draftMessage: resolvedDraftMessage, + selection: resolvedSelection, + anchor: resolvedAnchor, + } = useReportActionContextMenuData({ + reportID, + reportActionID, + originalReportID, + draftMessage, + selection: '', + anchor, + }); + + const hideAndRun = (callback?: () => void) => { + release(); + hideMiniContextMenu(); + callback?.(); + }; + + const openOverflowMenu = (event: GestureResponderEvent | MouseEvent, anchorRef: RefObject) => { + showContextMenu({ + type: CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + event, + selection: '', + contextMenuAnchor: anchorRef?.current ?? null, + report: { + reportID, + originalReportID, + }, + reportAction: { + reportActionID: reportAction?.reportActionID, + draftMessage, + }, + callbacks: { + onShow: checkIfContextMenuActive, + onHide: () => { + checkIfContextMenuActive?.(); + release(); + }, + }, + shouldCloseOnTarget: true, + isOverflowMenu: true, + }); + }; + + const currentUserAccountID = currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID; + + const isDisabledAction = (id: string) => disabledActionIDs.has(id); + + const showReplyInThread = + !isDisabledAction(ACTION_IDS.REPLY_IN_THREAD) && + shouldShowReplyInThreadAction({ + reportAction, + reportID: resolvedReportID, + isThreadReportParentAction, + isArchivedRoom, + }); + const showMarkAsUnread = !isDisabledAction(ACTION_IDS.MARK_AS_UNREAD) && shouldShowMarkAsUnreadForReportAction({reportAction}); + const showExplain = !isDisabledAction(ACTION_IDS.EXPLAIN) && shouldShowExplainAction({reportAction, isArchivedRoom}); + const showEdit = !isDisabledAction(ACTION_IDS.EDIT) && shouldShowEditAction({reportAction, isArchivedRoom, isChronosReport, moneyRequestAction, iouTransaction}); + const showUnhold = + !isDisabledAction(ACTION_IDS.UNHOLD) && + shouldShowUnholdAction({ + moneyRequestReport, + moneyRequestAction, + moneyRequestPolicy, + areHoldRequirementsMet, + iouTransaction, + currentUserAccountID, + }); + const showHold = + !isDisabledAction(ACTION_IDS.HOLD) && + shouldShowHoldAction({ + moneyRequestReport, + moneyRequestAction, + moneyRequestPolicy, + areHoldRequirementsMet, + iouTransaction, + currentUserAccountID, + }); + const showJoinThread = + !isDisabledAction(ACTION_IDS.JOIN_THREAD) && + shouldShowJoinThreadAction({ + reportAction, + isArchivedRoom, + isThreadReportParentAction, + isHarvestReport, + }); + const showLeaveThread = + !isDisabledAction(ACTION_IDS.LEAVE_THREAD) && + shouldShowLeaveThreadAction({ + reportAction, + isArchivedRoom, + isThreadReportParentAction, + isHarvestReport, + }); + const showCopyMessage = !isDisabledAction(ACTION_IDS.COPY_MESSAGE) && shouldShowCopyMessageAction({reportAction}); + const showCopyLink = !isDisabledAction(ACTION_IDS.COPY_LINK) && shouldShowCopyLinkAction({reportAction, menuTarget: resolvedAnchor}); + const showFlagAsOffensive = + !isDisabledAction(ACTION_IDS.FLAG_AS_OFFENSIVE) && shouldShowFlagAsOffensiveAction({reportAction, isArchivedRoom, isChronosReport, reportID: resolvedReportID}); + const showDownload = !isDisabledAction(ACTION_IDS.DOWNLOAD) && shouldShowDownloadAction({reportAction, isOffline}); + const showDelete = + !isDisabledAction(ACTION_IDS.DELETE) && + shouldShowDeleteAction({ + reportAction, + isArchivedRoom, + isChronosReport, + reportID: resolvedReportID, + moneyRequestAction, + iouTransaction, + transactions, + childReportActions, + }); + + const allVisibleItems: React.ReactElement[] = []; + if (reportAction) { + if (showReplyInThread) { + allVisibleItems.push( + , + ); + } + if (showMarkAsUnread) { + allVisibleItems.push( + , + ); + } + if (showExplain) { + allVisibleItems.push( + , + ); + } + if (showEdit) { + allVisibleItems.push( + , + ); + } + if (showUnhold) { + allVisibleItems.push( + , + ); + } + if (showHold) { + allVisibleItems.push( + , + ); + } + if (showJoinThread) { + allVisibleItems.push( + , + ); + } + if (showLeaveThread) { + allVisibleItems.push( + , + ); + } + if (showCopyMessage) { + allVisibleItems.push( + } + card={card} + originalReport={originalReport} + isHarvestReport={isHarvestReport} + isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} + movedFromReport={movedFromReport} + movedToReport={movedToReport} + childReport={childReport} + policy={policy} + getLocalDateFromDatetime={getLocalDateFromDatetime} + policyTags={policyTags} + harvestReport={harvestReport} + currentUserPersonalDetails={currentUserPersonalDetails} + />, + ); + } + if (showCopyLink) { + allVisibleItems.push( + , + ); + } + if (showFlagAsOffensive) { + allVisibleItems.push( + , + ); + } + if (showDownload) { + allVisibleItems.push( + , + ); + } + if (showDelete) { + allVisibleItems.push( + , + ); + } + } + + const needsOverflow = allVisibleItems.length > CONST.MINI_CONTEXT_MENU_MAX_ITEMS; + const displayedItems = needsOverflow ? allVisibleItems.slice(0, CONST.MINI_CONTEXT_MENU_MAX_ITEMS - 1) : allVisibleItems; + + const emojiData = createEmojiReactionData({ + reportID: resolvedReportID, + reportAction, + currentUserAccountID, + openContextMenu: () => keepOpen(), + setIsEmojiPickerActive, + hideAndRun, + }); + + const hasEmoji = shouldShowEmojiReaction({reportAction}) && !!emojiData.reportAction && !!emojiData.reportActionID; + + if (!isVisible || !rowMeasurements) { + return null; + } + + const wrapperStyle = StyleUtils.getReportActionContextMenuStyles(true, shouldUseNarrowLayout); return ( - - - + + + keepOpen()} + onHoverOut={() => { + release(); + hideMiniContextMenu(); + }} + > + + + {hasEmoji && !!emojiData.reportAction && !!emojiData.reportActionID && ( + + interceptAnonymousUser(() => emojiData.toggleEmojiAndCloseMenu(emoji, existingReactions, preferredSkinTone)) + } + onPressOpenPicker={emojiData.onPressOpenPicker} + onEmojiPickerClosed={emojiData.onEmojiPickerClosed} + reportActionID={emojiData.reportActionID} + reportAction={emojiData.reportAction} + /> + )} + {displayedItems} + {needsOverflow && ( + + interceptAnonymousUser(() => { + openOverflowMenu(new MouseEvent('click'), threeDotRef); + keepOpen(); + }, true) + } + isDelayButtonStateComplete={false} + shouldPreventDefaultFocusOnPress={false} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.MENU} + /> + )} + + + + + ); } diff --git a/src/pages/inbox/report/ContextMenu/MiniReportActionContextMenu/types.ts b/src/pages/inbox/report/ContextMenu/MiniReportActionContextMenu/types.ts deleted file mode 100644 index 59ec5195810c..000000000000 --- a/src/pages/inbox/report/ContextMenu/MiniReportActionContextMenu/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type {BaseReportActionContextMenuProps} from '@pages/inbox/report/ContextMenu/BaseReportActionContextMenu'; - -type MiniReportActionContextMenuProps = Omit & { - /** Should the reportAction this menu is attached to have the appearance of being grouped with the previous reportAction? */ - displayAsGroup?: boolean; -}; - -export default MiniReportActionContextMenuProps; diff --git a/src/pages/inbox/report/ContextMenu/PopoverContextMenu/ConfirmDeleteReportActionModal.tsx b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/ConfirmDeleteReportActionModal.tsx new file mode 100644 index 000000000000..484f2bf70f45 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/ConfirmDeleteReportActionModal.tsx @@ -0,0 +1,163 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {DeviceEventEmitter, InteractionManager} from 'react-native'; +import ConfirmModal from '@components/ConfirmModal'; +import type {ModalProps} from '@components/Modal/Global/ModalContext'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import {useSearchStateContext} from '@components/Search/SearchContext'; +import useAncestors from '@hooks/useAncestors'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDeleteTransactions from '@hooks/useDeleteTransactions'; +import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; +import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; +import {deleteAppReport, deleteReportComment} from '@libs/actions/Report'; +import {getOriginalMessage, isMoneyRequestAction, isReportPreviewAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; +import {getOriginalReportID} from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type ConfirmDeleteReportActionModalProps = ModalProps & { + reportID: string; + reportActionID: string; + actionSourceReportID?: string; +}; + +function ConfirmDeleteReportActionModal({closeModal, reportID, reportActionID, actionSourceReportID}: ConfirmDeleteReportActionModalProps) { + const {translate} = useLocalize(); + const {email, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const {currentSearchHash} = useSearchStateContext(); + + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); + // Track the latest reportActions so runAfterInteractions sees post-click updates + // (e.g. another action deleted in the meantime). Updated inside an effect so the + // ref isn't touched during render. + const reportActionsRef = useRef(reportActions); + useEffect(() => { + reportActionsRef.current = reportActions; + }, [reportActions]); + const [sourceReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${actionSourceReportID}`); + const actionReportActions = reportActions?.[reportActionID] ? reportActions : sourceReportActions; + const reportAction = actionReportActions?.[reportActionID]; + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction?.childReportID}`); + const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); + const [selfDMReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); + + const isReportArchived = useReportIsArchived(reportID); + const originalReportID = getOriginalReportID(reportID, reportAction, actionReportActions); + const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`); + const isOriginalReportArchived = useReportIsArchived(originalReportID); + const {iouReport, chatReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(reportAction); + + const transactionIDs: string[] = []; + if (isMoneyRequestAction(reportAction)) { + const originalMessage = getOriginalMessage(reportAction); + if (originalMessage && 'IOUTransactionID' in originalMessage && !!originalMessage.IOUTransactionID) { + transactionIDs.push(originalMessage.IOUTransactionID); + } + } + + const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactionIDs); + const {deleteTransactions} = useDeleteTransactions({ + report, + reportActions: reportAction ? [reportAction] : [], + policy, + }); + + const ancestors = useAncestors(originalReport); + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(originalReport?.iouReportID); + + const [isVisible, setIsVisible] = useState(true); + const [closeAction, setCloseAction] = useState(ModalActions.CLOSE); + + const handleConfirm = () => { + if (isMoneyRequestAction(reportAction)) { + const originalMessage = getOriginalMessage(reportAction); + if (isTrackExpenseAction(reportAction)) { + deleteTrackExpense({ + chatReportID: reportID, + chatReport: report, + transactionID: originalMessage?.IOUTransactionID, + reportAction, + iouReport, + chatIOUReport: chatReport, + transactions: duplicateTransactions, + violations: duplicateTransactionViolations, + isSingleTransactionView: undefined, + isChatReportArchived: isReportArchived, + isChatIOUReportArchived, + allTransactionViolationsParam: allTransactionViolations, + currentUserAccountID, + currentUserEmail: email ?? '', + }); + } else if (originalMessage?.IOUTransactionID) { + deleteTransactions([originalMessage.IOUTransactionID], duplicateTransactions, duplicateTransactionViolations, undefined); + } + } else if (isReportPreviewAction(reportAction)) { + deleteAppReport({ + report: childReport, + selfDMReport, + currentUserEmailParam: email ?? '', + currentUserAccountIDParam: currentUserAccountID, + reportTransactions, + allTransactionViolations, + bankAccountList, + hash: currentSearchHash, + }); + } else if (reportAction) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + deleteReportComment( + report, + reportAction, + ancestors, + isReportArchived, + isOriginalReportArchived, + email ?? '', + visibleReportActionsData ?? undefined, + reportActionsRef.current ?? undefined, + ); + }); + } + + DeviceEventEmitter.emit(`deletedReportAction_${reportID}`, reportAction?.reportActionID); + setCloseAction(ModalActions.CONFIRM); + setIsVisible(false); + }; + + const handleCancel = () => { + setCloseAction(ModalActions.CLOSE); + setIsVisible(false); + }; + + const handleModalHide = () => { + if (isVisible) { + return; + } + closeModal({action: closeAction}); + }; + + return ( + + ); +} + +export default ConfirmDeleteReportActionModal; diff --git a/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverEmailContent.tsx b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverEmailContent.tsx new file mode 100644 index 000000000000..0e7bf4ec06dc --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverEmailContent.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {View as ViewType} from 'react-native'; +import {View} from 'react-native'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Clipboard from '@libs/Clipboard'; +import EmailUtils from '@libs/EmailUtils'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; + +type PopoverEmailContentProps = { + selection: string; + contentRef: React.RefObject; +}; + +function PopoverEmailContent({selection, contentRef}: PopoverEmailContentProps) { + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const icons = useMemoizedLazyExpensifyIcons(['Copy', 'Checkmark'] as const); + + const handlePress = () => { + interceptAnonymousUser(() => { + Clipboard.setString(EmailUtils.trimMailTo(selection)); + hideContextMenu(true, ReportActionComposeFocusManager.focus); + }, true); + }; + + const wrapperStyle = StyleUtils.getReportActionContextMenuStyles(false, shouldUseNarrowLayout); + const description = EmailUtils.prefixMailSeparatorsWithBreakOpportunities(EmailUtils.trimMailTo(selection ?? '')); + + return ( + + + + ); +} + +export default PopoverEmailContent; diff --git a/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverLinkContent.tsx b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverLinkContent.tsx new file mode 100644 index 000000000000..cb9a104475e7 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverLinkContent.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {View as ViewType} from 'react-native'; +import {View} from 'react-native'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Clipboard from '@libs/Clipboard'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; + +type PopoverLinkContentProps = { + selection: string; + contentRef: React.RefObject; +}; + +function PopoverLinkContent({selection, contentRef}: PopoverLinkContentProps) { + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const icons = useMemoizedLazyExpensifyIcons(['Copy', 'Checkmark'] as const); + + const handlePress = () => { + interceptAnonymousUser(() => { + Clipboard.setString(selection); + hideContextMenu(true, ReportActionComposeFocusManager.focus); + }, true); + }; + + const wrapperStyle = StyleUtils.getReportActionContextMenuStyles(false, shouldUseNarrowLayout); + + return ( + + + + ); +} + +export default PopoverLinkContent; diff --git a/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverReportActionContent.tsx b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverReportActionContent.tsx new file mode 100644 index 000000000000..00a08c1333d9 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverReportActionContent.tsx @@ -0,0 +1,494 @@ +import type {RefObject} from 'react'; +import React from 'react'; +import {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, View as ViewType} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import FocusableMenuItem from '@components/FocusableMenuItem'; +import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; +import QuickEmojiReactions from '@components/Reactions/QuickEmojiReactions'; +import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {ACTION_IDS} from '@pages/inbox/report/ContextMenu/actions/actionConfig'; +import {shouldShowCopyLinkAction} from '@pages/inbox/report/ContextMenu/actions/CopyLinkAction/copyLinkAction'; +import PopoverCopyLinkItem from '@pages/inbox/report/ContextMenu/actions/CopyLinkAction/PopoverCopyLinkItem'; +import {shouldShowCopyMessageAction} from '@pages/inbox/report/ContextMenu/actions/CopyMessageAction/copyMessageAction'; +import PopoverCopyMessageItem from '@pages/inbox/report/ContextMenu/actions/CopyMessageAction/PopoverCopyMessageItem'; +import {PopoverDebugItem, shouldShowDebugAction} from '@pages/inbox/report/ContextMenu/actions/DebugAction'; +import {shouldShowDeleteAction} from '@pages/inbox/report/ContextMenu/actions/DeleteAction/deleteAction'; +import PopoverDeleteItem from '@pages/inbox/report/ContextMenu/actions/DeleteAction/PopoverDeleteItem'; +import {shouldShowDownloadAction} from '@pages/inbox/report/ContextMenu/actions/DownloadAction/downloadAction'; +import PopoverDownloadItem from '@pages/inbox/report/ContextMenu/actions/DownloadAction/PopoverDownloadItem'; +import {shouldShowEditAction} from '@pages/inbox/report/ContextMenu/actions/EditAction/editAction'; +import PopoverEditItem from '@pages/inbox/report/ContextMenu/actions/EditAction/PopoverEditItem'; +import createEmojiReactionData, {shouldShowEmojiReaction} from '@pages/inbox/report/ContextMenu/actions/emojiReactionAction'; +import {shouldShowExplainAction} from '@pages/inbox/report/ContextMenu/actions/ExplainAction/explainAction'; +import PopoverExplainItem from '@pages/inbox/report/ContextMenu/actions/ExplainAction/PopoverExplainItem'; +import {shouldShowFlagAsOffensiveAction} from '@pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/flagAsOffensiveAction'; +import PopoverFlagAsOffensiveItem from '@pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/PopoverFlagAsOffensiveItem'; +import {shouldShowHoldAction} from '@pages/inbox/report/ContextMenu/actions/HoldAction/holdAction'; +import PopoverHoldItem from '@pages/inbox/report/ContextMenu/actions/HoldAction/PopoverHoldItem'; +import {shouldShowJoinThreadAction} from '@pages/inbox/report/ContextMenu/actions/JoinThreadAction/joinThreadAction'; +import PopoverJoinThreadItem from '@pages/inbox/report/ContextMenu/actions/JoinThreadAction/PopoverJoinThreadItem'; +import {shouldShowLeaveThreadAction} from '@pages/inbox/report/ContextMenu/actions/LeaveThreadAction/leaveThreadAction'; +import PopoverLeaveThreadItem from '@pages/inbox/report/ContextMenu/actions/LeaveThreadAction/PopoverLeaveThreadItem'; +import {shouldShowMarkAsUnreadForReportAction} from '@pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/markAsUnreadAction'; +import PopoverMarkAsUnreadItem from '@pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/PopoverMarkAsUnreadItem'; +import PopoverReplyInThreadItem from '@pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/PopoverReplyInThreadItem'; +import {shouldShowReplyInThreadAction} from '@pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/replyInThreadAction'; +import PopoverUnholdItem from '@pages/inbox/report/ContextMenu/actions/UnholdAction/PopoverUnholdItem'; +import {shouldShowUnholdAction} from '@pages/inbox/report/ContextMenu/actions/UnholdAction/unholdAction'; +import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import useReportActionContextMenuData from '@pages/inbox/report/ContextMenu/useReportActionContextMenuData'; +import CONST from '@src/CONST'; +import type {BankAccountList} from '@src/types/onyx'; + +type PopoverReportActionContentProps = { + reportID: string | undefined; + reportActionID: string | undefined; + originalReportID: string | undefined; + draftMessage: string | undefined; + selection: string; + contextMenuTargetNode: HTMLDivElement | null; + onEmojiPickerToggle: ((state: boolean) => void) | undefined; + hideAndRun: (callback?: () => void) => void; + setLocalShouldKeepOpen: (value: boolean) => void; + contentRef: RefObject; + shouldEnableArrowNavigation: boolean; +}; + +function PopoverReportActionContent({ + reportID, + reportActionID, + originalReportID, + draftMessage, + selection, + contextMenuTargetNode, + onEmojiPickerToggle, + hideAndRun, + setLocalShouldKeepOpen, + contentRef, + shouldEnableArrowNavigation, +}: PopoverReportActionContentProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowWidth} = useWindowDimensions(); + const {translate} = useLocalize(); + const overflowIcons = useMemoizedLazyExpensifyIcons(['ThreeDots'] as const); + + const { + report, + reportAction, + reportActions: reportActionsMap, + originalReport, + childReport, + childReportActions, + policy, + policyTags, + moneyRequestAction, + moneyRequestReport, + moneyRequestPolicy, + iouTransaction, + transaction, + bankAccountList, + card, + conciergeReportID, + currentUserPersonalDetails, + encryptedAuthToken, + isArchivedRoom, + isChronosReport, + isThreadReportParentAction, + isOffline, + isHarvestReport, + isTryNewDotNVPDismissed, + isDelegateAccessRestricted, + areHoldRequirementsMet, + isDebugModeEnabled, + transactions, + introSelected, + isSelfTourViewed, + betas, + movedFromReport, + movedToReport, + harvestReport, + download, + disabledActionIDs, + showDelegateNoAccessModal, + getLocalDateFromDatetime, + reportID: resolvedReportID, + originalReportID: resolvedOriginalReportID, + draftMessage: resolvedDraftMessage, + selection: resolvedSelection, + anchor, + } = useReportActionContextMenuData({ + reportID, + reportActionID, + originalReportID, + draftMessage: draftMessage ?? '', + selection: selection ?? '', + anchor: {current: contextMenuTargetNode ?? null}, + }); + + const openOverflowMenu = (event: GestureResponderEvent | MouseEvent) => { + showContextMenu({ + type: CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + event, + selection: selection ?? '', + contextMenuAnchor: null, + report: { + reportID, + originalReportID, + }, + reportAction: { + reportActionID: reportAction?.reportActionID, + draftMessage, + }, + callbacks: { + onShow: undefined, + onHide: () => { + setLocalShouldKeepOpen(false); + }, + }, + shouldCloseOnTarget: true, + isOverflowMenu: true, + }); + }; + + const currentUserAccountID = currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID; + + const isDisabled = (id: string) => disabledActionIDs.has(id); + + const showReplyInThread = + !isDisabled(ACTION_IDS.REPLY_IN_THREAD) && + shouldShowReplyInThreadAction({ + reportAction, + reportID: resolvedReportID, + isThreadReportParentAction, + isArchivedRoom, + }); + const showMarkAsUnread = !isDisabled(ACTION_IDS.MARK_AS_UNREAD) && shouldShowMarkAsUnreadForReportAction({reportAction}); + const showExplain = !isDisabled(ACTION_IDS.EXPLAIN) && shouldShowExplainAction({reportAction, isArchivedRoom}); + const showEdit = !isDisabled(ACTION_IDS.EDIT) && shouldShowEditAction({reportAction, isArchivedRoom, isChronosReport, moneyRequestAction, iouTransaction}); + const showUnhold = + !isDisabled(ACTION_IDS.UNHOLD) && + shouldShowUnholdAction({ + moneyRequestReport, + moneyRequestAction, + moneyRequestPolicy, + areHoldRequirementsMet, + iouTransaction, + currentUserAccountID, + }); + const showHold = + !isDisabled(ACTION_IDS.HOLD) && + shouldShowHoldAction({ + moneyRequestReport, + moneyRequestAction, + moneyRequestPolicy, + areHoldRequirementsMet, + iouTransaction, + currentUserAccountID, + }); + const showJoinThread = + !isDisabled(ACTION_IDS.JOIN_THREAD) && + shouldShowJoinThreadAction({ + reportAction, + isArchivedRoom, + isThreadReportParentAction, + isHarvestReport, + }); + const showLeaveThread = + !isDisabled(ACTION_IDS.LEAVE_THREAD) && + shouldShowLeaveThreadAction({ + reportAction, + isArchivedRoom, + isThreadReportParentAction, + isHarvestReport, + }); + const showCopyMessage = !isDisabled(ACTION_IDS.COPY_MESSAGE) && shouldShowCopyMessageAction({reportAction}); + const showCopyLink = !isDisabled(ACTION_IDS.COPY_LINK) && shouldShowCopyLinkAction({reportAction, menuTarget: anchor}); + const showFlagAsOffensive = !isDisabled(ACTION_IDS.FLAG_AS_OFFENSIVE) && shouldShowFlagAsOffensiveAction({reportAction, isArchivedRoom, isChronosReport, reportID: resolvedReportID}); + const showDownload = !isDisabled(ACTION_IDS.DOWNLOAD) && shouldShowDownloadAction({reportAction, isOffline}); + const showDebug = !isDisabled(ACTION_IDS.DEBUG) && shouldShowDebugAction({isDebugModeEnabled}); + const showDelete = + !isDisabled(ACTION_IDS.DELETE) && + shouldShowDeleteAction({ + reportAction, + isArchivedRoom, + isChronosReport, + reportID: resolvedReportID, + moneyRequestAction, + iouTransaction, + transactions, + childReportActions, + }); + + const visibleItems: React.ReactElement[] = []; + if (!reportAction) { + visibleItems.push( + + interceptAnonymousUser(() => { + openOverflowMenu(event as GestureResponderEvent | MouseEvent); + setLocalShouldKeepOpen(true); + }, true) + } + isAnonymousAction + wrapperStyle={[styles.pr8]} + style={StyleUtils.getContextMenuItemStyles(windowWidth)} + interactive + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.MENU} + />, + ); + } else { + if (showReplyInThread) { + visibleItems.push( + , + ); + } + if (showMarkAsUnread) { + visibleItems.push( + , + ); + } + if (showExplain) { + visibleItems.push( + , + ); + } + if (showEdit) { + visibleItems.push( + , + ); + } + if (showUnhold) { + visibleItems.push( + , + ); + } + if (showHold) { + visibleItems.push( + , + ); + } + if (showJoinThread) { + visibleItems.push( + , + ); + } + if (showLeaveThread) { + visibleItems.push( + , + ); + } + if (showCopyMessage) { + visibleItems.push( + } + card={card} + originalReport={originalReport} + isHarvestReport={isHarvestReport} + isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} + movedFromReport={movedFromReport} + movedToReport={movedToReport} + childReport={childReport} + policy={policy} + getLocalDateFromDatetime={getLocalDateFromDatetime} + policyTags={policyTags} + harvestReport={harvestReport} + currentUserPersonalDetails={currentUserPersonalDetails} + />, + ); + } + if (showCopyLink) { + visibleItems.push( + , + ); + } + if (showFlagAsOffensive) { + visibleItems.push( + , + ); + } + if (showDownload) { + visibleItems.push( + , + ); + } + if (showDebug) { + visibleItems.push( + , + ); + } + if (showDelete) { + visibleItems.push( + , + ); + } + } + + const emojiData = createEmojiReactionData({ + reportID: resolvedReportID, + reportAction, + currentUserAccountID, + openContextMenu: () => setLocalShouldKeepOpen(true), + setIsEmojiPickerActive: onEmojiPickerToggle, + hideAndRun, + }); + + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ + initialFocusedIndex: -1, + disabledIndexes: [], + maxIndex: visibleItems.length - 1, + isActive: shouldEnableArrowNavigation, + }); + + const hasEmoji = shouldShowEmojiReaction({reportAction}); + const wrapperStyle = StyleUtils.getReportActionContextMenuStyles(false, shouldUseNarrowLayout); + + return ( + + + + {hasEmoji && emojiData.reportActionID != null && emojiData.reportAction != null && ( + + interceptAnonymousUser(() => emojiData.toggleEmojiAndCloseMenu(emoji, existingReactions, preferredSkinTone)) + } + reportActionID={emojiData.reportActionID} + reportAction={emojiData.reportAction} + setIsEmojiPickerActive={(active) => { + if (!active) { + return; + } + setLocalShouldKeepOpen(true); + }} + /> + )} + {visibleItems.map((item, i) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + React.cloneElement(item as React.ReactElement, { + isFocused: focusedIndex === i, + onFocus: () => setFocusedIndex(i), + onBlur: () => (i === visibleItems.length - 1 || i === 1) && setFocusedIndex(-1), + }), + )} + + + + ); +} + +export default PopoverReportActionContent; diff --git a/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverReportContent.tsx b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverReportContent.tsx new file mode 100644 index 000000000000..0ec354e81996 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverReportContent.tsx @@ -0,0 +1,151 @@ +import type {RefObject} from 'react'; +import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {View as ViewType} from 'react-native'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useEnvironment from '@hooks/useEnvironment'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import {canWriteInReport, isUnread} from '@libs/ReportUtils'; +import {ACTION_IDS, RESTRICTED_READONLY_ACTION_IDS} from '@pages/inbox/report/ContextMenu/actions/actionConfig'; +import {PopoverCopyOnyxDataItem, shouldShowCopyOnyxDataAction} from '@pages/inbox/report/ContextMenu/actions/CopyOnyxDataAction'; +import {PopoverDebugItem, shouldShowDebugAction} from '@pages/inbox/report/ContextMenu/actions/DebugAction'; +import {PopoverMarkAsReadItem, shouldShowMarkAsReadAction} from '@pages/inbox/report/ContextMenu/actions/MarkAsReadAction'; +import {shouldShowMarkAsUnreadForReport} from '@pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/markAsUnreadAction'; +import PopoverMarkAsUnreadItem from '@pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/PopoverMarkAsUnreadItem'; +import {PopoverPinItem, shouldShowPinAction} from '@pages/inbox/report/ContextMenu/actions/PinAction'; +import {PopoverUnpinItem, shouldShowUnpinAction} from '@pages/inbox/report/ContextMenu/actions/UnpinAction'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; + +type PopoverReportContentProps = { + reportID: string | undefined; + reportActionID: string | undefined; + originalReportID: string | undefined; + hideAndRun: (callback?: () => void) => void; + contentRef: RefObject; + shouldEnableArrowNavigation: boolean; +}; + +const EMPTY_SET = new Set(); + +function PopoverReportContent({reportID, reportActionID, originalReportID, hideAndRun, contentRef, shouldEnableArrowNavigation}: PopoverReportContentProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const StyleUtils = useStyleUtils(); + const {isProduction} = useEnvironment(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserAccountID = currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID; + + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {canEvict: false}); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); + + const isOriginalReportArchived = useReportIsArchived(originalReportID); + + const disabledActionIDs = !canWriteInReport(report) ? RESTRICTED_READONLY_ACTION_IDS : EMPTY_SET; + const isDisabled = (id: string) => disabledActionIDs.has(id); + + const hasValidReportAction = reportActions && reportActionID && reportActionID !== '0' && reportActionID !== '-1'; + const reportAction: OnyxEntry = hasValidReportAction ? reportActions[reportActionID] : undefined; + + const isPinnedChat = !!report?.isPinned; + const isUnreadChat = isUnread(report, undefined, isOriginalReportArchived); + + const showMarkAsRead = shouldShowMarkAsReadAction({isUnreadChat}) && !isDisabled(ACTION_IDS.MARK_AS_READ); + const showMarkAsUnread = shouldShowMarkAsUnreadForReport({isUnreadChat}) && !isDisabled(ACTION_IDS.MARK_AS_UNREAD); + const showPin = shouldShowPinAction({isPinnedChat}) && !isDisabled(ACTION_IDS.PIN); + const showUnpin = shouldShowUnpinAction({isPinnedChat}) && !isDisabled(ACTION_IDS.UNPIN); + const showCopyOnyxData = shouldShowCopyOnyxDataAction({isProduction}) && !isDisabled(ACTION_IDS.COPY_ONYX_DATA); + const showDebug = shouldShowDebugAction({isDebugModeEnabled}) && !isDisabled(ACTION_IDS.DEBUG); + + const visibleItems: React.ReactElement[] = []; + if (showMarkAsRead) { + visibleItems.push( + , + ); + } + if (showMarkAsUnread) { + visibleItems.push( + , + ); + } + if (showPin) { + visibleItems.push( + , + ); + } + if (showUnpin) { + visibleItems.push( + , + ); + } + if (showCopyOnyxData) { + visibleItems.push( + , + ); + } + if (showDebug && reportAction) { + visibleItems.push( + , + ); + } + + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ + initialFocusedIndex: -1, + disabledIndexes: [], + maxIndex: visibleItems.length - 1, + isActive: shouldEnableArrowNavigation, + }); + + const wrapperStyle = StyleUtils.getReportActionContextMenuStyles(false, shouldUseNarrowLayout); + + return ( + + {visibleItems.map((item, i) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + React.cloneElement(item as React.ReactElement, { + isFocused: focusedIndex === i, + onFocus: () => setFocusedIndex(i), + onBlur: () => (i === visibleItems.length - 1 || i === 0) && setFocusedIndex(-1), + }), + )} + + ); +} + +export default PopoverReportContent; diff --git a/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverTextContent.tsx b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverTextContent.tsx new file mode 100644 index 000000000000..f45bbbd2b5e2 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverTextContent.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {View as ViewType} from 'react-native'; +import {View} from 'react-native'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Clipboard from '@libs/Clipboard'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; + +type PopoverTextContentProps = { + selection: string; + contentRef: React.RefObject; +}; + +function PopoverTextContent({selection, contentRef}: PopoverTextContentProps) { + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const icons = useMemoizedLazyExpensifyIcons(['Copy', 'Checkmark'] as const); + + const handlePress = () => { + interceptAnonymousUser(() => { + Clipboard.setString(selection); + hideContextMenu(true, ReportActionComposeFocusManager.focus); + }, true); + }; + + const wrapperStyle = StyleUtils.getReportActionContextMenuStyles(false, shouldUseNarrowLayout); + + return ( + + + + ); +} + +export default PopoverTextContent; diff --git a/src/pages/inbox/report/ContextMenu/PopoverContextMenu/index.tsx b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/index.tsx new file mode 100644 index 000000000000..d25353e8fae3 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/PopoverContextMenu/index.tsx @@ -0,0 +1,387 @@ +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, NativeTouchEvent, View as ViewType} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import {Dimensions} from 'react-native'; +import {Actions, useActionSheetAwareScrollViewActions} from '@components/ActionSheetAwareScrollView'; +import {ModalActions, useModal} from '@components/Modal/Global/ModalContext'; +import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; +import useRestoreInputFocus from '@hooks/useRestoreInputFocus'; +import calculateAnchorPosition from '@libs/calculateAnchorPosition'; +import refocusComposerAfterPreventFirstResponder from '@libs/refocusComposerAfterPreventFirstResponder'; +import type {ComposerType} from '@libs/ReportActionComposeFocusManager'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import type {ContextMenuAnchor, ContextMenuType, ReportActionContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import ConfirmDeleteReportActionModal from './ConfirmDeleteReportActionModal'; +import PopoverEmailContent from './PopoverEmailContent'; +import PopoverLinkContent from './PopoverLinkContent'; +import PopoverReportActionContent from './PopoverReportActionContent'; +import PopoverReportContent from './PopoverReportContent'; +import PopoverTextContent from './PopoverTextContent'; + +function extractPointerEvent(event: GestureResponderEvent | MouseEvent): MouseEvent | NativeTouchEvent { + if ('nativeEvent' in event) { + return event.nativeEvent; + } + return event; +} + +type PopoverPosition = { + anchorHorizontal: number; + anchorVertical: number; + anchorWidth: number; + anchorHeight: number; +}; + +type PopoverContextMenuProps = { + ref?: React.Ref; +}; + +function PopoverContextMenu({ref: forwardedRef}: PopoverContextMenuProps) { + const {transitionActionSheetState} = useActionSheetAwareScrollViewActions(); + const modalContext = useModal(); + + const [type, setType] = useState(null); + const [reportID, setReportID] = useState(); + const [reportActionID, setReportActionID] = useState(); + const [originalReportID, setOriginalReportID] = useState(); + const [selection, setSelection] = useState(''); + const [draftMessage, setDraftMessage] = useState(); + const [isOverflowMenu, setIsOverflowMenu] = useState(false); + const [withoutOverlay, setWithoutOverlay] = useState(true); + const [popoverPosition, setPopoverPosition] = useState({anchorHorizontal: 0, anchorVertical: 0, anchorWidth: 0, anchorHeight: 0}); + const [contextMenuTargetNode, setContextMenuTargetNode] = useState(null); + const [onEmojiPickerToggle, setOnEmojiPickerToggle] = useState<((state: boolean) => void) | undefined>(); + const [isPopoverVisible, setIsPopoverVisible] = useState(false); + const [isContextMenuOpening, setIsContextMenuOpening] = useState(false); + const [composerToRefocusOnClose, setComposerToRefocusOnClose] = useState(); + const [localShouldKeepOpen, setLocalShouldKeepOpen] = useState(false); + + const cursorRelativePosition = useRef({horizontal: 0, vertical: 0}); + const instanceIDRef = useRef(''); + + const contentRef = useRef(null); + const anchorRef = useRef(null); + const contextMenuAnchorRef = useRef(null); + + const onPopoverShow = useRef(() => {}); + const onPopoverHide = useRef(() => {}); + const onPopoverHideActionCallback = useRef(() => {}); + + useRestoreInputFocus(isPopoverVisible); + + const getContextMenuMeasuredLocation = () => + new Promise<{x: number; y: number}>((resolve) => { + if (contextMenuAnchorRef.current && 'measureInWindow' in contextMenuAnchorRef.current && typeof contextMenuAnchorRef.current.measureInWindow === 'function') { + contextMenuAnchorRef.current.measureInWindow((x, y) => resolve({x, y})); + } else { + resolve({x: 0, y: 0}); + } + }); + + useEffect(() => { + if (!isPopoverVisible) { + return; + } + + const listener = Dimensions.addEventListener('change', () => { + new Promise<{x: number; y: number}>((resolve) => { + if (contextMenuAnchorRef.current && 'measureInWindow' in contextMenuAnchorRef.current && typeof contextMenuAnchorRef.current.measureInWindow === 'function') { + contextMenuAnchorRef.current.measureInWindow((x, y) => resolve({x, y})); + } else { + resolve({x: 0, y: 0}); + } + }).then(({x, y}) => { + if (!x || !y) { + return; + } + + setPopoverPosition((prev) => ({ + ...prev, + anchorHorizontal: cursorRelativePosition.current.horizontal + x, + anchorVertical: cursorRelativePosition.current.vertical + y, + })); + }); + }); + + return () => { + listener.remove(); + }; + }, [isPopoverVisible]); + + const isActiveReportAction: ReportActionContextMenu['isActiveReportAction'] = (actionID) => !!actionID && reportActionID === String(actionID); + + const clearActiveReportAction = () => { + setType(null); + setReportID(undefined); + setReportActionID(undefined); + setOriginalReportID(undefined); + setSelection(''); + setDraftMessage(undefined); + setIsOverflowMenu(false); + setWithoutOverlay(true); + setPopoverPosition({anchorHorizontal: 0, anchorVertical: 0, anchorWidth: 0, anchorHeight: 0}); + setContextMenuTargetNode(null); + setOnEmojiPickerToggle(undefined); + }; + + const showContextMenuHandler: ReportActionContextMenu['showContextMenu'] = (showContextMenuParams) => { + const { + type: showType, + event, + selection: showSelection, + contextMenuAnchor, + report: currentReport = {}, + reportAction: reportActionParam = {}, + callbacks = {}, + shouldCloseOnTarget = false, + isOverflowMenu: showIsOverflowMenu = false, + withoutOverlay: showWithoutOverlay = true, + } = showContextMenuParams; + if (ReportActionComposeFocusManager.isFocused()) { + setComposerToRefocusOnClose('main'); + } else if (ReportActionComposeFocusManager.isEditFocused()) { + setComposerToRefocusOnClose('edit'); + } + + const {reportID: showReportID, originalReportID: showOriginalReportID} = currentReport; + const {reportActionID: showReportActionID, draftMessage: showDraftMessage} = reportActionParam; + const {onShow = () => {}, onHide = () => {}, setIsEmojiPickerActive = () => {}} = callbacks; + setIsContextMenuOpening(true); + + const {pageX = 0, pageY = 0} = extractPointerEvent(event); + contextMenuAnchorRef.current = contextMenuAnchor; + const targetNode = event.target as HTMLDivElement; + if (shouldCloseOnTarget) { + anchorRef.current = targetNode; + } else { + anchorRef.current = null; + } + + onPopoverShow.current = onShow; + onPopoverHide.current = onHide; + + new Promise((resolve) => { + const anchor = contextMenuAnchorRef.current; + const useAnchorPosition = showIsOverflowMenu || (anchor != null && !pageX && !pageY); + if (useAnchorPosition && anchor) { + calculateAnchorPosition(anchor).then((position) => { + resolve({ + anchorHorizontal: position.horizontal, + anchorVertical: position.vertical, + anchorWidth: position.width, + anchorHeight: position.height, + }); + }); + } else { + getContextMenuMeasuredLocation().then(({x, y}) => { + cursorRelativePosition.current = { + horizontal: pageX - x, + vertical: pageY - y, + }; + resolve({ + anchorHorizontal: pageX, + anchorVertical: pageY, + anchorWidth: 0, + anchorHeight: 0, + }); + }); + } + }).then((position) => { + setType(showType); + setReportID(showReportID); + setReportActionID(showReportActionID); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + setOriginalReportID(showOriginalReportID || undefined); + setSelection(showSelection); + setDraftMessage(showDraftMessage); + setIsOverflowMenu(showIsOverflowMenu); + setWithoutOverlay(showWithoutOverlay); + setPopoverPosition(position); + setContextMenuTargetNode(targetNode); + setOnEmojiPickerToggle(() => setIsEmojiPickerActive); + setIsPopoverVisible(true); + }); + }; + + const runAndResetOnPopoverShow = () => { + instanceIDRef.current = Math.random().toString(36).slice(2, 7); + onPopoverShow.current(); + onPopoverShow.current = () => {}; + setTimeout(() => { + setIsContextMenuOpening(false); + }, CONST.ANIMATED_TRANSITION); + }; + + const runAndResetCallback = (callback: () => void) => { + callback(); + return () => {}; + }; + + const runAndResetOnPopoverHide = () => { + clearActiveReportAction(); + instanceIDRef.current = ''; + + onPopoverHide.current = runAndResetCallback(onPopoverHide.current); + onPopoverHideActionCallback.current = runAndResetCallback(onPopoverHideActionCallback.current); + }; + + const hideContextMenuHandler: ReportActionContextMenu['hideContextMenu'] = (hideContextMenuParams) => { + const {callbacks = {}} = hideContextMenuParams ?? {}; + + if (typeof callbacks.onHide === 'function') { + onPopoverHideActionCallback.current = callbacks.onHide; + } + + setIsPopoverVisible(false); + + transitionActionSheetState({ + type: Actions.CLOSE_POPOVER, + }); + + refocusComposerAfterPreventFirstResponder(composerToRefocusOnClose).then(() => { + setComposerToRefocusOnClose(undefined); + }); + }; + + const isDeleteModalActiveRef = useRef(false); + + const hideDeleteModal = () => { + if (!isDeleteModalActiveRef.current) { + return; + } + modalContext.closeModal(); + }; + + const showDeleteModal: ReportActionContextMenu['showDeleteModal'] = ( + showReportID, + showReportAction, + _shouldSetModalVisibility, + onConfirm = () => {}, + onCancel = () => {}, + actionSourceReportID = undefined, + ) => { + if (!showReportID || !showReportAction?.reportActionID) { + return; + } + + setReportID(showReportID); + setReportActionID(showReportAction.reportActionID); + + isDeleteModalActiveRef.current = true; + modalContext + .showModal({ + component: ConfirmDeleteReportActionModal, + props: { + reportID: showReportID, + reportActionID: showReportAction.reportActionID, + actionSourceReportID, + }, + }) + .then((result) => { + isDeleteModalActiveRef.current = false; + if (result.action === ModalActions.CONFIRM) { + onConfirm(); + } else { + onCancel(); + } + clearActiveReportAction(); + }); + }; + + useImperativeHandle(forwardedRef, () => ({ + showContextMenu: showContextMenuHandler, + hideContextMenu: hideContextMenuHandler, + showDeleteModal, + hideDeleteModal, + isActiveReportAction, + instanceIDRef, + runAndResetOnPopoverHide, + clearActiveReportAction, + contentRef, + isContextMenuOpening, + composerToRefocusOnCloseEmojiPicker: composerToRefocusOnClose, + })); + + const hideAndRun = (callback?: () => void) => { + hideContextMenu(false, callback); + }; + + const shouldKeepOpen = localShouldKeepOpen; + const shouldEnableArrowNavigation = isPopoverVisible || shouldKeepOpen; + + return ( + hideContextMenuHandler()} + onModalShow={runAndResetOnPopoverShow} + onModalHide={runAndResetOnPopoverHide} + anchorPosition={{ + horizontal: popoverPosition.anchorHorizontal, + vertical: popoverPosition.anchorVertical, + }} + animationIn="fadeIn" + disableAnimation={false} + shouldSetModalVisibility={false} + fullscreen + withoutOverlay={withoutOverlay} + anchorDimensions={{ + width: popoverPosition.anchorWidth, + height: popoverPosition.anchorHeight, + }} + anchorRef={anchorRef} + shouldSwitchPositionIfOverflow={isOverflowMenu} + > + {type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && ( + + )} + {type === CONST.CONTEXT_MENU_TYPES.REPORT && ( + + )} + {type === CONST.CONTEXT_MENU_TYPES.LINK && ( + + )} + {type === CONST.CONTEXT_MENU_TYPES.EMAIL && ( + + )} + {type === CONST.CONTEXT_MENU_TYPES.TEXT && ( + + )} + + ); +} + +PopoverContextMenu.displayName = 'PopoverContextMenu'; + +export default PopoverContextMenu; +export type {PopoverPosition}; diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx deleted file mode 100644 index c918824154a3..000000000000 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ /dev/null @@ -1,518 +0,0 @@ -import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; -/* eslint-disable no-restricted-imports */ -import type {EmitterSubscription, GestureResponderEvent, NativeTouchEvent, View} from 'react-native'; -import {DeviceEventEmitter, Dimensions, InteractionManager} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {Actions, useActionSheetAwareScrollViewActions} from '@components/ActionSheetAwareScrollView'; -import ConfirmModal from '@components/ConfirmModal'; -import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; -import {useSearchStateContext} from '@components/Search/SearchContext'; -import useAncestors from '@hooks/useAncestors'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDeleteTransactions from '@hooks/useDeleteTransactions'; -import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; -import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useReportIsArchived from '@hooks/useReportIsArchived'; -import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; -import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; -import {deleteAppReport, deleteReportComment} from '@libs/actions/Report'; -import calculateAnchorPosition from '@libs/calculateAnchorPosition'; -import refocusComposerAfterPreventFirstResponder from '@libs/refocusComposerAfterPreventFirstResponder'; -import type {ComposerType} from '@libs/ReportActionComposeFocusManager'; -import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; -import {getOriginalMessage, isMoneyRequestAction, isReportPreviewAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; -import {getOriginalReportID} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {AnchorDimensions} from '@src/styles'; -import type {ReportAction} from '@src/types/onyx'; -import type {Location} from '@src/types/utils/Layout'; -import BaseReportActionContextMenu from './BaseReportActionContextMenu'; -import type {ContextMenuAction} from './ContextMenuActions'; -import type {ContextMenuAnchor, ContextMenuType, ReportActionContextMenu} from './ReportActionContextMenu'; - -function extractPointerEvent(event: GestureResponderEvent | MouseEvent): MouseEvent | NativeTouchEvent { - if ('nativeEvent' in event) { - return event.nativeEvent; - } - return event; -} - -type PopoverReportActionContextMenuProps = { - /** Reference to the outer element */ - ref?: ForwardedRef; -}; - -function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuProps) { - const {translate} = useLocalize(); - const reportIDRef = useRef(undefined); - const typeRef = useRef(undefined); - const reportActionRef = useRef> | null>(null); - const reportActionIDRef = useRef(undefined); - const originalReportIDRef = useRef(undefined); - const selectionRef = useRef(''); - const reportActionDraftMessageRef = useRef(undefined); - const isReportArchived = useReportIsArchived(reportIDRef.current); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDRef.current}`); - const reportActionsRef = useRef(reportActions); - reportActionsRef.current = reportActions; - const isOriginalReportArchived = useReportIsArchived(getOriginalReportID(reportIDRef.current, reportActionRef.current, reportActions)); - const {iouReport, chatReport, isChatIOUReportArchived} = useGetIOUReportFromReportAction(reportActionRef.current); - const {transitionActionSheetState} = useActionSheetAwareScrollViewActions(); - - const cursorRelativePosition = useRef({ - horizontal: 0, - vertical: 0, - }); - - // The horizontal and vertical position (relative to the screen) where the popover will display. - const popoverAnchorPosition = useRef({ - horizontal: 0, - vertical: 0, - }); - const instanceIDRef = useRef(''); - const {email, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - - const [isPopoverVisible, setIsPopoverVisible] = useState(false); - const [isDeleteCommentConfirmModalVisible, setIsDeleteCommentConfirmModalVisible] = useState(false); - const [shouldSetModalVisibilityForDeleteConfirmation, setShouldSetModalVisibilityForDeleteConfirmation] = useState(true); - - const [isRoomArchived, setIsRoomArchived] = useState(false); - const [isChronosReportEnabled, setIsChronosReportEnabled] = useState(false); - const [isChatPinned, setIsChatPinned] = useState(false); - const [hasUnreadMessages, setHasUnreadMessages] = useState(false); - const [isThreadReportParentAction, setIsThreadReportParentAction] = useState(false); - const [disabledActions, setDisabledActions] = useState([]); - const [shouldSwitchPositionIfOverflow, setShouldSwitchPositionIfOverflow] = useState(false); - const [isWithoutOverlay, setIsWithoutOverlay] = useState(true); - const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - - const contentRef = useRef(null); - const anchorRef = useRef(null); - const dimensionsEventListener = useRef(null); - const contextMenuAnchorRef = useRef(null); - const contextMenuTargetNode = useRef(null); - const contextMenuDimensions = useRef({ - width: 0, - height: 0, - }); - - const [composerToRefocusOnClose, setComposerToRefocusOnClose] = useState(); - - const onPopoverShow = useRef(() => {}); - const [isContextMenuOpening, setIsContextMenuOpening] = useState(false); - const onPopoverHide = useRef(() => {}); - const onEmojiPickerToggle = useRef void)>(undefined); - const onCancelDeleteModal = useRef(() => {}); - const onConfirmDeleteModal = useRef(() => {}); - - const onPopoverHideActionCallback = useRef(() => {}); - const callbackWhenDeleteModalHide = useRef(() => {}); - - /** Get the Context menu anchor position. We calculate the anchor coordinates from measureInWindow async method */ - const getContextMenuMeasuredLocation = useCallback( - () => - new Promise((resolve) => { - if (contextMenuAnchorRef.current && 'measureInWindow' in contextMenuAnchorRef.current && typeof contextMenuAnchorRef.current.measureInWindow === 'function') { - contextMenuAnchorRef.current.measureInWindow((x, y) => resolve({x, y})); - } else { - resolve({x: 0, y: 0}); - } - }), - [], - ); - - /** This gets called on Dimensions change to find the anchor coordinates for the action context menu. */ - const measureContextMenuAnchorPosition = useCallback(() => { - if (!isPopoverVisible) { - return; - } - - getContextMenuMeasuredLocation().then(({x, y}) => { - if (!x || !y) { - return; - } - - popoverAnchorPosition.current = { - horizontal: cursorRelativePosition.current.horizontal + x, - vertical: cursorRelativePosition.current.vertical + y, - }; - }); - }, [isPopoverVisible, getContextMenuMeasuredLocation]); - - useEffect(() => { - dimensionsEventListener.current = Dimensions.addEventListener('change', measureContextMenuAnchorPosition); - - return () => { - if (!dimensionsEventListener.current) { - return; - } - dimensionsEventListener.current.remove(); - }; - }, [measureContextMenuAnchorPosition]); - - /** Whether Context Menu is active for the Report Action. */ - const isActiveReportAction: ReportActionContextMenu['isActiveReportAction'] = (actionID) => - !!actionID && (reportActionIDRef.current === actionID || reportActionRef.current?.reportActionID === actionID); - - const clearActiveReportAction = () => { - reportActionIDRef.current = undefined; - reportActionRef.current = null; - }; - - /** - * Show the ReportActionContextMenu modal popover. - * - * @param type - context menu type [EMAIL, LINK, REPORT_ACTION] - * @param [event] - A press event. - * @param [selection] - Copied content. - * @param contextMenuAnchor - popoverAnchor - * @param reportID - Active Report Id - * @param reportActionID - ReportAction for ContextMenu - * @param originalReportID - The current Report Id of the reportAction - * @param draftMessage - ReportAction draft message - * @param [onShow] - Run a callback when Menu is shown - * @param [onHide] - Run a callback when Menu is hidden - * @param isArchivedRoom - Whether the provided report is an archived room - * @param isChronosReport - Flag to check if the chat participant is Chronos - * @param isPinnedChat - Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action - * @param isUnreadChat - Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action - */ - const showContextMenu: ReportActionContextMenu['showContextMenu'] = (showContextMenuParams) => { - const { - type, - event, - selection, - contextMenuAnchor, - report: currentReport = {}, - reportAction = {}, - callbacks = {}, - disabledOptions = [], - shouldCloseOnTarget = false, - isOverflowMenu = false, - withoutOverlay = true, - } = showContextMenuParams; - if (ReportActionComposeFocusManager.isFocused()) { - setComposerToRefocusOnClose('main'); - } else if (ReportActionComposeFocusManager.isEditFocused()) { - setComposerToRefocusOnClose('edit'); - } - - const {reportID, originalReportID, isArchivedRoom = false, isChronos = false, isPinnedChat = false, isUnreadChat = false} = currentReport; - const {reportActionID, draftMessage, isThreadReportParentAction: isThreadReportParentActionParam = false} = reportAction; - const {onShow = () => {}, onHide = () => {}, setIsEmojiPickerActive = () => {}} = callbacks; - setIsContextMenuOpening(true); - setIsWithoutOverlay(withoutOverlay); - const {pageX = 0, pageY = 0} = extractPointerEvent(event); - contextMenuAnchorRef.current = contextMenuAnchor; - contextMenuTargetNode.current = event.target as HTMLDivElement; - if (shouldCloseOnTarget) { - anchorRef.current = event.target as HTMLDivElement; - } else { - anchorRef.current = null; - } - - onPopoverShow.current = onShow; - onPopoverHide.current = onHide; - onEmojiPickerToggle.current = setIsEmojiPickerActive; - - new Promise((resolve) => { - if (!!(!pageX && !pageY && contextMenuAnchorRef.current) || isOverflowMenu) { - calculateAnchorPosition(contextMenuAnchorRef.current).then((position) => { - popoverAnchorPosition.current = {horizontal: position.horizontal, vertical: position.vertical}; - contextMenuDimensions.current = {width: position.vertical, height: position.height}; - resolve(); - }); - } else { - getContextMenuMeasuredLocation().then(({x, y}) => { - cursorRelativePosition.current = { - horizontal: pageX - x, - vertical: pageY - y, - }; - popoverAnchorPosition.current = { - horizontal: pageX, - vertical: pageY, - }; - resolve(); - }); - } - }).then(() => { - setDisabledActions(disabledOptions); - typeRef.current = type; - reportIDRef.current = reportID; - reportActionIDRef.current = reportActionID; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - originalReportIDRef.current = originalReportID || undefined; - selectionRef.current = selection; - setIsPopoverVisible(true); - reportActionDraftMessageRef.current = draftMessage; - setIsRoomArchived(isArchivedRoom); - setIsChronosReportEnabled(isChronos); - setIsChatPinned(isPinnedChat); - setHasUnreadMessages(isUnreadChat); - setIsThreadReportParentAction(isThreadReportParentActionParam); - setShouldSwitchPositionIfOverflow(isOverflowMenu); - }); - }; - - /** After Popover shows, call the registered onPopoverShow callback and reset it */ - const runAndResetOnPopoverShow = () => { - instanceIDRef.current = Math.random().toString(36).slice(2, 7); - onPopoverShow.current(); - - // After we have called the action, reset it. - onPopoverShow.current = () => {}; - - // After the context menu opening animation ends reset isContextMenuOpening. - setTimeout(() => { - setIsContextMenuOpening(false); - }, CONST.ANIMATED_TRANSITION); - }; - - /** Run the callback and return a noop function to reset it */ - const runAndResetCallback = (callback: () => void) => { - callback(); - return () => {}; - }; - - /** After Popover hides, call the registered onPopoverHide & onPopoverHideActionCallback callback and reset it */ - const runAndResetOnPopoverHide = () => { - reportIDRef.current = undefined; - reportActionIDRef.current = undefined; - originalReportIDRef.current = undefined; - instanceIDRef.current = ''; - selectionRef.current = ''; - - onPopoverHide.current = runAndResetCallback(onPopoverHide.current); - onPopoverHideActionCallback.current = runAndResetCallback(onPopoverHideActionCallback.current); - }; - - /** - * Hide the ReportActionContextMenu modal popover. - * @param onHideActionCallback Callback to be called after popover is completely hidden - */ - const hideContextMenu: ReportActionContextMenu['hideContextMenu'] = (hideContextMenuParams) => { - const {callbacks = {}} = hideContextMenuParams ?? {}; - - if (typeof callbacks.onHide === 'function') { - onPopoverHideActionCallback.current = callbacks.onHide; - } - - selectionRef.current = ''; - reportActionDraftMessageRef.current = undefined; - setIsPopoverVisible(false); - - transitionActionSheetState({ - type: Actions.CLOSE_POPOVER, - }); - - refocusComposerAfterPreventFirstResponder(composerToRefocusOnClose).then(() => { - setComposerToRefocusOnClose(undefined); - }); - }; - - const transactionIDs: string[] = []; - if (isMoneyRequestAction(reportActionRef.current)) { - const originalMessage = getOriginalMessage(reportActionRef.current); - if (originalMessage && 'IOUTransactionID' in originalMessage && !!originalMessage.IOUTransactionID) { - transactionIDs.push(originalMessage.IOUTransactionID); - } - } - - const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactionIDs); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDRef.current}`); - const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportActionRef.current?.childReportID}`); - const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); - const [selfDMReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); - const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const {currentSearchHash} = useSearchStateContext(); - const {deleteTransactions} = useDeleteTransactions({ - report, - reportActions: reportActionRef.current ? [reportActionRef.current] : [], - policy, - }); - - const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getOriginalReportID(reportIDRef.current, reportActionRef.current, reportActions)}`); - const ancestorsRef = useRef([]); - const ancestors = useAncestors(originalReport); - const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(originalReport?.iouReportID); - useEffect(() => { - if (!originalReport) { - return; - } - ancestorsRef.current = ancestors; - }, [originalReport, ancestors]); - const confirmDeleteAndHideModal = useCallback(() => { - callbackWhenDeleteModalHide.current = runAndResetCallback(onConfirmDeleteModal.current); - const reportAction = reportActionRef.current; - if (isMoneyRequestAction(reportAction)) { - const originalMessage = getOriginalMessage(reportAction); - if (isTrackExpenseAction(reportAction)) { - deleteTrackExpense({ - chatReportID: reportIDRef.current, - chatReport: report, - transactionID: originalMessage?.IOUTransactionID, - reportAction, - iouReport, - chatIOUReport: chatReport, - transactions: duplicateTransactions, - violations: duplicateTransactionViolations, - isSingleTransactionView: undefined, - isChatReportArchived: isReportArchived, - isChatIOUReportArchived, - allTransactionViolationsParam: allTransactionViolations, - currentUserAccountID, - currentUserEmail: email ?? '', - }); - } else if (originalMessage?.IOUTransactionID) { - deleteTransactions([originalMessage.IOUTransactionID], duplicateTransactions, duplicateTransactionViolations, undefined); - } - } else if (isReportPreviewAction(reportAction)) { - deleteAppReport({ - report: childReport, - selfDMReport, - currentUserEmailParam: email ?? '', - currentUserAccountIDParam: currentUserAccountID, - reportTransactions, - allTransactionViolations, - bankAccountList, - hash: currentSearchHash, - }); - } else if (reportAction) { - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - deleteReportComment( - report, - reportAction, - ancestorsRef.current, - isReportArchived, - isOriginalReportArchived, - email ?? '', - visibleReportActionsData ?? undefined, - reportActionsRef.current ?? undefined, - ); - }); - } - - DeviceEventEmitter.emit(`deletedReportAction_${reportIDRef.current}`, reportAction?.reportActionID); - setIsDeleteCommentConfirmModalVisible(false); - }, [ - report, - childReport, - selfDMReport, - iouReport, - chatReport, - duplicateTransactions, - duplicateTransactionViolations, - isReportArchived, - isChatIOUReportArchived, - allTransactionViolations, - currentUserAccountID, - deleteTransactions, - currentSearchHash, - email, - reportTransactions, - bankAccountList, - isOriginalReportArchived, - visibleReportActionsData, - ]); - - const hideDeleteModal = () => { - callbackWhenDeleteModalHide.current = () => (onCancelDeleteModal.current = runAndResetCallback(onCancelDeleteModal.current)); - setIsDeleteCommentConfirmModalVisible(false); - setShouldSetModalVisibilityForDeleteConfirmation(true); - setIsRoomArchived(false); - setIsChronosReportEnabled(false); - setIsChatPinned(false); - setHasUnreadMessages(false); - }; - - /** Opens the Confirm delete action modal */ - const showDeleteModal: ReportActionContextMenu['showDeleteModal'] = (reportID, reportAction, shouldSetModalVisibility = true, onConfirm = () => {}, onCancel = () => {}) => { - onCancelDeleteModal.current = onCancel; - - onConfirmDeleteModal.current = onConfirm; - reportIDRef.current = reportID; - reportActionRef.current = reportAction ?? null; - - setShouldSetModalVisibilityForDeleteConfirmation(shouldSetModalVisibility); - setIsDeleteCommentConfirmModalVisible(true); - }; - - useImperativeHandle(ref, () => ({ - showContextMenu, - hideContextMenu, - showDeleteModal, - hideDeleteModal, - isActiveReportAction, - instanceIDRef, - runAndResetOnPopoverHide, - clearActiveReportAction, - contentRef, - isContextMenuOpening, - composerToRefocusOnCloseEmojiPicker: composerToRefocusOnClose, - })); - - const reportAction = reportActionRef.current; - - return ( - <> - hideContextMenu()} - onModalShow={runAndResetOnPopoverShow} - onModalHide={runAndResetOnPopoverHide} - anchorPosition={popoverAnchorPosition.current} - animationIn="fadeIn" - disableAnimation={false} - shouldSetModalVisibility={false} - fullscreen - withoutOverlay={isWithoutOverlay} - anchorDimensions={contextMenuDimensions.current} - anchorRef={anchorRef} - shouldSwitchPositionIfOverflow={shouldSwitchPositionIfOverflow} - > - - - { - clearActiveReportAction(); - callbackWhenDeleteModalHide.current(); - }} - prompt={translate('reportActionContextMenu.deleteConfirmation', {action: reportAction})} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - - ); -} - -export default PopoverReportActionContextMenu; diff --git a/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts index 2da4ac2bbffc..c268d097ce3d 100644 --- a/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/inbox/report/ContextMenu/ReportActionContextMenu.ts @@ -7,7 +7,6 @@ import type {ValueOf} from 'type-fest'; import type {ComposerType} from '@libs/ReportActionComposeFocusManager'; import type CONST from '@src/CONST'; import type {ReportAction} from '@src/types/onyx'; -import type {ContextMenuAction} from './ContextMenuActions'; type OnConfirm = () => void; @@ -26,21 +25,16 @@ type ShowContextMenuParams = { reportID?: string; originalReportID?: string; isArchivedRoom?: boolean; - isChronos?: boolean; - isPinnedChat?: boolean; - isUnreadChat?: boolean; }; reportAction?: { reportActionID?: string; draftMessage?: string; - isThreadReportParentAction?: boolean; }; callbacks?: { onShow?: () => void; onHide?: () => void; setIsEmojiPickerActive?: (state: boolean) => void; }; - disabledOptions?: ContextMenuAction[]; shouldCloseOnTarget?: boolean; isOverflowMenu?: boolean; withoutOverlay?: boolean; @@ -58,7 +52,14 @@ type HideContextMenu = (params?: HideContextMenuParams) => void; type ReportActionContextMenu = { showContextMenu: ShowContextMenu; hideContextMenu: HideContextMenu; - showDeleteModal: (reportID: string, reportAction: OnyxEntry, shouldSetModalVisibility?: boolean, onConfirm?: OnConfirm, onCancel?: OnCancel) => void; + showDeleteModal: ( + reportID: string, + reportAction: OnyxEntry, + shouldSetModalVisibility?: boolean, + onConfirm?: OnConfirm, + onCancel?: OnCancel, + actionSourceReportID?: string, + ) => void; hideDeleteModal: () => void; isActiveReportAction: (accountID: string | number) => boolean; instanceIDRef: RefObject; @@ -157,11 +158,18 @@ function hideDeleteModal() { /** * Opens the Confirm delete action modal */ -function showDeleteModal(reportID: string | undefined, reportAction: OnyxEntry, shouldSetModalVisibility?: boolean, onConfirm?: OnConfirm, onCancel?: OnCancel) { +function showDeleteModal( + reportID: string | undefined, + reportAction: OnyxEntry, + shouldSetModalVisibility?: boolean, + onConfirm?: OnConfirm, + onCancel?: OnCancel, + actionSourceReportID?: string, +) { if (!contextMenuRef.current || !reportID) { return; } - contextMenuRef.current.showDeleteModal(reportID, reportAction, shouldSetModalVisibility, onConfirm, onCancel); + contextMenuRef.current.showDeleteModal(reportID, reportAction, shouldSetModalVisibility, onConfirm, onCancel, actionSourceReportID); } /** diff --git a/src/pages/inbox/report/ContextMenu/actions/CopyLinkAction/MiniCopyLinkItem.tsx b/src/pages/inbox/report/ContextMenu/actions/CopyLinkAction/MiniCopyLinkItem.tsx new file mode 100644 index 000000000000..5525bae6f118 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/CopyLinkAction/MiniCopyLinkItem.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import Clipboard from '@libs/Clipboard'; +import {getEnvironmentURL} from '@libs/Environment/Environment'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +type MiniCopyLinkItemProps = { + reportAction: ReportAction; + originalReportID: string | undefined; +}; + +export default function MiniCopyLinkItem({reportAction, originalReportID}: MiniCopyLinkItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['LinkCopy', 'Checkmark'] as const); + + return ( + + interceptAnonymousUser(() => { + getEnvironmentURL().then((environmentURL) => { + const reportActionID = reportAction?.reportActionID; + Clipboard.setString(`${environmentURL}/r/${originalReportID}/${reportActionID}`); + }); + hideContextMenu(true, ReportActionComposeFocusManager.focus); + }, true) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_LINK} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/CopyLinkAction/PopoverCopyLinkItem.tsx b/src/pages/inbox/report/ContextMenu/actions/CopyLinkAction/PopoverCopyLinkItem.tsx new file mode 100644 index 000000000000..35908afba8cb --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/CopyLinkAction/PopoverCopyLinkItem.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import Clipboard from '@libs/Clipboard'; +import {getEnvironmentURL} from '@libs/Environment/Environment'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +type PopoverCopyLinkItemProps = { + reportAction: ReportAction; + originalReportID: string | undefined; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverCopyLinkItem({reportAction, originalReportID, isFocused, onFocus, onBlur}: PopoverCopyLinkItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['LinkCopy', 'Checkmark'] as const); + + return ( + + interceptAnonymousUser(() => { + getEnvironmentURL().then((environmentURL) => { + const reportActionID = reportAction?.reportActionID; + Clipboard.setString(`${environmentURL}/r/${originalReportID}/${reportActionID}`); + }); + hideContextMenu(true, ReportActionComposeFocusManager.focus); + }, true) + } + isAnonymousAction + isFocused={isFocused} + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_LINK} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/CopyLinkAction/copyLinkAction.ts b/src/pages/inbox/report/ContextMenu/actions/CopyLinkAction/copyLinkAction.ts new file mode 100644 index 000000000000..576120fdd3f2 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/CopyLinkAction/copyLinkAction.ts @@ -0,0 +1,16 @@ +import type {RefObject} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {isActionOfType, isMessageDeleted, isReportActionAttachment} from '@libs/ReportActionsUtils'; +import type {ContextMenuAnchor} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +function shouldShowCopyLinkAction({reportAction, menuTarget}: {reportAction: OnyxEntry; menuTarget: RefObject | undefined}): boolean { + const isAttachment = isReportActionAttachment(reportAction); + const isAttachmentTarget = menuTarget?.current && 'tagName' in menuTarget.current && menuTarget?.current.tagName === 'IMG' && isAttachment; + const isDEWRouted = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED); + return !isAttachmentTarget && !isMessageDeleted(reportAction) && !isDEWRouted; +} + +// eslint-disable-next-line import/prefer-default-export -- named utility export per module convention +export {shouldShowCopyLinkAction}; diff --git a/src/pages/inbox/report/ContextMenu/actions/CopyMessageAction/MiniCopyMessageItem.tsx b/src/pages/inbox/report/ContextMenu/actions/CopyMessageAction/MiniCopyMessageItem.tsx new file mode 100644 index 000000000000..6d13ecbe63e4 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/CopyMessageAction/MiniCopyMessageItem.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {CopyMessageClipboardParams} from './copyMessageAction'; +import {copyMessageToClipboard} from './copyMessageAction'; + +type MiniCopyMessageItemProps = Omit; + +export default function MiniCopyMessageItem({ + reportAction, + transaction, + selection, + report, + conciergeReportID, + bankAccountList, + card, + originalReport, + isHarvestReport, + isTryNewDotNVPDismissed, + movedFromReport, + movedToReport, + childReport, + policy, + getLocalDateFromDatetime, + policyTags, + harvestReport, + currentUserPersonalDetails, +}: MiniCopyMessageItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Copy', 'Checkmark'] as const); + + return ( + + interceptAnonymousUser(() => { + copyMessageToClipboard({ + reportAction, + transaction, + selection, + report, + conciergeReportID, + bankAccountList, + card, + originalReport, + isHarvestReport, + isTryNewDotNVPDismissed, + movedFromReport, + movedToReport, + childReport, + policy, + getLocalDateFromDatetime, + policyTags, + translate, + harvestReport, + currentUserPersonalDetails, + }); + hideContextMenu(true, ReportActionComposeFocusManager.focus); + }, true) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_MESSAGE} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/CopyMessageAction/PopoverCopyMessageItem.tsx b/src/pages/inbox/report/ContextMenu/actions/CopyMessageAction/PopoverCopyMessageItem.tsx new file mode 100644 index 000000000000..3dc53d5cbe37 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/CopyMessageAction/PopoverCopyMessageItem.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {CopyMessageClipboardParams} from './copyMessageAction'; +import {copyMessageToClipboard} from './copyMessageAction'; + +type PopoverCopyMessageItemProps = Omit & { + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverCopyMessageItem({ + reportAction, + transaction, + selection, + report, + conciergeReportID, + bankAccountList, + card, + originalReport, + isHarvestReport, + isTryNewDotNVPDismissed, + movedFromReport, + movedToReport, + childReport, + policy, + getLocalDateFromDatetime, + policyTags, + harvestReport, + currentUserPersonalDetails, + isFocused, + onFocus, + onBlur, +}: PopoverCopyMessageItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Copy', 'Checkmark'] as const); + + return ( + + interceptAnonymousUser(() => { + copyMessageToClipboard({ + reportAction, + transaction, + selection, + report, + conciergeReportID, + bankAccountList, + card, + originalReport, + isHarvestReport, + isTryNewDotNVPDismissed, + movedFromReport, + movedToReport, + childReport, + policy, + getLocalDateFromDatetime, + policyTags, + translate, + harvestReport, + currentUserPersonalDetails, + }); + hideContextMenu(true, ReportActionComposeFocusManager.focus); + }, true) + } + isAnonymousAction + isFocused={isFocused} + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_MESSAGE} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/CopyMessageAction/copyMessageAction.ts b/src/pages/inbox/report/ContextMenu/actions/CopyMessageAction/copyMessageAction.ts new file mode 100644 index 000000000000..0d1e5acf8892 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/CopyMessageAction/copyMessageAction.ts @@ -0,0 +1,576 @@ +import {Str} from 'expensify-common'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; +import type useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import Clipboard from '@libs/Clipboard'; +import getClipboardText from '@libs/Clipboard/getClipboardText'; +import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from '@libs/LocalePhoneNumber'; +import {getForReportAction} from '@libs/ModifiedExpenseMessage'; +import Parser from '@libs/Parser'; +import {getCleanedTagName, isPolicyAdmin} from '@libs/PolicyUtils'; +import stripFollowupListFromHtml from '@libs/ReportActionFollowupUtils/stripFollowupListFromHtml'; +import { + getActionableCard3DSTransactionApprovalMessage, + getActionableCardFraudAlertMessage, + getActionableMentionWhisperMessage, + getAddedApprovalRuleMessage, + getAddedBudgetMessage, + getAddedCardFeedMessage, + getAddedConnectionMessage, + getAssignedCompanyCardMessage, + getAutoPayApprovedReportsEnabledMessage, + getAutoReimbursementMessage, + getCardIssuedMessage, + getChangedApproverActionMessage, + getCompanyAddressUpdateMessage, + getCompanyCardConnectionBrokenMessage, + getCreatedReportForUnapprovedTransactionsMessage, + getCurrencyDefaultTaxUpdateMessage, + getCustomTaxNameUpdateMessage, + getDefaultApproverUpdateMessage, + getDeletedApprovalRuleMessage, + getDeletedBudgetMessage, + getDismissedViolationMessageText, + getDynamicExternalWorkflowApproveFailedActionMessage, + getDynamicExternalWorkflowRoutedMessage, + getDynamicExternalWorkflowSubmitFailedActionMessage, + getExportIntegrationMessageHTML, + getForeignCurrencyDefaultTaxUpdateMessage, + getForwardsToUpdateMessage, + getHarvestCreatedExpenseReportMessage, + getIntegrationSyncFailedMessage, + getInvoiceCompanyNameUpdateMessage, + getInvoiceCompanyWebsiteUpdateMessage, + getIOUReportIDFromReportActionPreview, + getJoinRequestMessage, + getMarkedReimbursedMessage, + getMemberChangeMessageFragment, + getMessageOfOldDotReportAction, + getOriginalMessage, + getPlaidBalanceFailureMessage, + getPolicyChangeLogAddEmployeeMessage, + getPolicyChangeLogDefaultBillableMessage, + getPolicyChangeLogDefaultReimbursableMessage, + getPolicyChangeLogDefaultTitleEnforcedMessage, + getPolicyChangeLogDeleteMemberMessage, + getPolicyChangeLogMaxExpenseAgeMessage, + getPolicyChangeLogMaxExpenseAmountMessage, + getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, + getPolicyChangeLogUpdateEmployee, + getReimburserUpdateMessage, + getRemovedCardFeedMessage, + getRemovedConnectionMessage, + getRenamedAction, + getRenamedCardFeedMessage, + getReportActionMessageFragments, + getReportActionMessageText, + getRoomAvatarUpdatedMessage, + getSetAutoJoinMessage, + getSettlementAccountLockedMessage, + getSubmitsToUpdateMessage, + getTagListNameUpdatedMessage, + getTagListUpdatedMessage, + getTagListUpdatedRequiredMessage, + getTravelUpdateMessage, + getUnassignedCompanyCardMessage, + getUpdateACHAccountMessage, + getUpdatedApprovalRuleMessage, + getUpdatedAuditRateMessage, + getUpdatedAutoHarvestingMessage, + getUpdatedBudgetMessage, + getUpdatedCardFeedLiabilityMessage, + getUpdatedCardFeedStatementPeriodMessage, + getUpdatedDefaultTitleMessage, + getUpdatedIndividualBudgetNotificationMessage, + getUpdatedManualApprovalThresholdMessage, + getUpdatedOwnershipMessage, + getUpdatedProhibitedExpensesMessage, + getUpdatedReimbursementChoiceMessage, + getUpdatedSharedBudgetNotificationMessage, + getUpdatedTimeEnabledMessage, + getUpdatedTimeRateMessage, + getUpdateRoomDescriptionMessage, + getWorkspaceAttendeeTrackingUpdateMessage, + getWorkspaceCategoriesUpdatedMessage, + getWorkspaceCategoryUpdateMessage, + getWorkspaceCurrencyUpdateMessage, + getWorkspaceCustomUnitRateAddedMessage, + getWorkspaceCustomUnitRateDeletedMessage, + getWorkspaceCustomUnitRateImportedMessage, + getWorkspaceCustomUnitRateUpdatedMessage, + getWorkspaceCustomUnitSubRateDeletedMessage, + getWorkspaceCustomUnitSubRateUpdatedMessage, + getWorkspaceCustomUnitUpdatedMessage, + getWorkspaceDescriptionUpdatedMessage, + getWorkspaceFeatureEnabledMessage, + getWorkspaceFrequencyUpdateMessage, + getWorkspaceReimbursementUpdateMessage, + getWorkspaceReportFieldAddMessage, + getWorkspaceReportFieldDeleteMessage, + getWorkspaceReportFieldUpdateMessage, + getWorkspaceTagUpdateMessage, + getWorkspaceTaxUpdateMessage, + getWorkspaceUpdateFieldMessage, + isActionableJoinRequest, + isActionableMentionWhisper, + isActionableTrackExpense, + isActionOfType, + isCardIssuedAction, + isCreatedTaskReportAction, + isDynamicExternalWorkflowApproveFailedAction, + isDynamicExternalWorkflowSubmitFailedAction, + isMarkAsClosedAction, + isMemberChangeAction, + isMessageDeleted, + isModifiedExpenseAction, + isMoneyRequestAction, + isMovedAction, + isOldDotReportAction, + isOriginalReportDeleted, + isReimbursementDeQueuedOrCanceledAction, + isReimbursementQueuedAction, + isRejectedAction, + isRenamedAction, + isReportActionAttachment, + isReportPreviewAction as isReportPreviewActionReportActionsUtils, + isTagModificationAction, + isTaskAction as isTaskActionReportActionsUtils, + isTripPreview, + isUnapprovedAction, +} from '@libs/ReportActionsUtils'; +import {getReportName} from '@libs/ReportNameUtils'; +import { + getDeletedTransactionMessage, + getIOUReportActionDisplayMessage, + getMovedActionMessage, + getMovedTransactionMessage, + getPolicyChangeMessage, + getReimbursementDeQueuedOrCanceledActionMessage, + getReimbursementQueuedActionMessage, + getReportName as getReportNameDeprecated, + getReportOrDraftReport, + getReportPreviewMessage, + getUnreportedTransactionMessage, + getWorkspaceNameUpdatedMessage, + isExpenseReport, +} from '@libs/ReportUtils'; +import {getTaskCreatedMessage, getTaskReportActionMessage} from '@libs/TaskUtils'; +import CONST from '@src/CONST'; +import type {BankAccountList, Card, Policy, PolicyTagLists, ReportAction, Report as ReportType, Transaction} from '@src/types/onyx'; +// eslint-disable-next-line @dword-design/import-alias/prefer-alias -- sibling of actions/ from CopyMessageAction subfolder +import {getActionHtml} from '../actionConfig'; + +type CopyMessageClipboardParams = { + reportAction: ReportAction; + transaction: OnyxEntry; + selection: string; + report: OnyxEntry; + conciergeReportID: string | undefined; + bankAccountList: OnyxEntry; + card: Card | undefined; + originalReport: OnyxEntry; + isHarvestReport: boolean; + isTryNewDotNVPDismissed: boolean; + movedFromReport: OnyxEntry; + movedToReport: OnyxEntry; + childReport: OnyxEntry; + policy: OnyxEntry; + getLocalDateFromDatetime: LocaleContextProps['getLocalDateFromDatetime']; + policyTags: OnyxEntry; + translate: LocalizedTranslate; + harvestReport: OnyxEntry; + currentUserPersonalDetails: ReturnType; +}; + +function shouldShowCopyMessageAction({reportAction}: {reportAction: OnyxEntry}): boolean { + return !isReportActionAttachment(reportAction) && !isMessageDeleted(reportAction) && !isTripPreview(reportAction); +} + +function setClipboardMessage(content: string | undefined) { + const strippedContent = stripFollowupListFromHtml(content); + if (!strippedContent) { + return; + } + const clipboardText = getClipboardText(strippedContent); + if (!Clipboard.canSetHtml()) { + Clipboard.setString(clipboardText); + } else { + Clipboard.setHtml(strippedContent, clipboardText); + } +} + +function copyMessageToClipboard(params: CopyMessageClipboardParams) { + const { + reportAction, + transaction, + selection, + report, + conciergeReportID, + bankAccountList, + card, + originalReport, + isHarvestReport = false, + isTryNewDotNVPDismissed = false, + movedFromReport, + movedToReport, + childReport, + policy, + getLocalDateFromDatetime, + policyTags, + translate, + harvestReport, + currentUserPersonalDetails, + } = params; + + const isReportPreviewAction = isReportPreviewActionReportActionsUtils(reportAction); + const messageHtml = getActionHtml(reportAction); + const messageText = getReportActionMessageText(reportAction); + const isAttachment = isReportActionAttachment(reportAction); + + if (!isAttachment) { + const content = selection || messageHtml; + if (isReportPreviewAction) { + const iouReportID = getIOUReportIDFromReportActionPreview(reportAction); + const displayMessage = getReportPreviewMessage(iouReportID, conciergeReportID, reportAction, undefined, undefined, undefined, undefined, undefined, true); + Clipboard.setString(displayMessage); + } else if (isTaskActionReportActionsUtils(reportAction)) { + const {text, html} = getTaskReportActionMessage(translate, reportAction); + const displayMessage = html ?? text; + setClipboardMessage(displayMessage); + } else if (isModifiedExpenseAction(reportAction)) { + const modifyExpenseMessageWithHTML = getForReportAction({ + translate, + reportAction, + policy, + movedFromReport, + movedToReport, + policyTags, + currentUserLogin: (currentUserPersonalDetails as {email?: string})?.email ?? (currentUserPersonalDetails as {login?: string})?.login ?? '', + }); + const modifyExpenseMessage = Parser.htmlToMarkdown(modifyExpenseMessageWithHTML); + Clipboard.setString(modifyExpenseMessage); + } else if (isReimbursementDeQueuedOrCanceledAction(reportAction)) { + const displayMessage = getReimbursementDeQueuedOrCanceledActionMessage(translate, reportAction, report); + Clipboard.setString(displayMessage); + } else if (isMoneyRequestAction(reportAction)) { + const displayMessage = getIOUReportActionDisplayMessage(translate, reportAction, transaction, report, bankAccountList); + if (displayMessage === Parser.htmlToText(displayMessage)) { + Clipboard.setString(displayMessage); + } else { + setClipboardMessage(displayMessage); + } + } else if (isCreatedTaskReportAction(reportAction)) { + const taskPreviewMessage = getTaskCreatedMessage(translate, reportAction, childReport, true); + Clipboard.setString(taskPreviewMessage); + } else if (isMemberChangeAction(reportAction)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + const logMessage = getMemberChangeMessageFragment(translate, reportAction, getReportNameDeprecated).html ?? ''; + setClipboardMessage(logMessage); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME) { + Clipboard.setString(Str.htmlDecode(getWorkspaceNameUpdatedMessage(translate, reportAction))); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DESCRIPTION) { + setClipboardMessage(getWorkspaceDescriptionUpdatedMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY) { + Clipboard.setString(getWorkspaceCurrencyUpdateMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY) { + Clipboard.setString(getWorkspaceFrequencyUpdateMessage(translate, reportAction)); + } else if ( + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY || + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY || + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY || + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME + ) { + Clipboard.setString(getWorkspaceCategoryUpdateMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORIES) { + Clipboard.setString(getWorkspaceCategoriesUpdatedMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.IMPORT_TAGS) { + Clipboard.setString(translate('workspaceActions.importTags')); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_ALL_TAGS) { + Clipboard.setString(translate('workspaceActions.deletedAllTags')); + } else if ( + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAX || + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAX || + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAX + ) { + Clipboard.setString(getWorkspaceTaxUpdateMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_TAX_NAME) { + Clipboard.setString(getCustomTaxNameUpdateMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY_DEFAULT_TAX) { + Clipboard.setString(getCurrencyDefaultTaxUpdateMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FOREIGN_CURRENCY_DEFAULT_TAX) { + Clipboard.setString(getForeignCurrencyDefaultTaxUpdateMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST_NAME) { + Clipboard.setString(getCleanedTagName(getTagListNameUpdatedMessage(translate, reportAction))); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST) { + Clipboard.setString(getCleanedTagName(getTagListUpdatedMessage(translate, reportAction))); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST_REQUIRED) { + Clipboard.setString(getCleanedTagName(getTagListUpdatedRequiredMessage(translate, reportAction))); + } else if (isTagModificationAction(reportAction.actionName)) { + Clipboard.setString(getCleanedTagName(getWorkspaceTagUpdateMessage(translate, reportAction))); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT) { + Clipboard.setString(getWorkspaceCustomUnitUpdatedMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.IMPORT_CUSTOM_UNIT_RATES) { + Clipboard.setString(getWorkspaceCustomUnitRateImportedMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT_RATE) { + Clipboard.setString(getWorkspaceCustomUnitRateAddedMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT_RATE) { + Clipboard.setString(getWorkspaceCustomUnitRateUpdatedMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_RATE) { + Clipboard.setString(getWorkspaceCustomUnitRateDeletedMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT_SUB_RATE) { + Clipboard.setString(getWorkspaceCustomUnitSubRateUpdatedMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_SUB_RATE) { + Clipboard.setString(getWorkspaceCustomUnitSubRateDeletedMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_REPORT_FIELD) { + Clipboard.setString(getWorkspaceReportFieldAddMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REPORT_FIELD) { + Clipboard.setString(getWorkspaceReportFieldUpdateMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_REPORT_FIELD) { + Clipboard.setString(getWorkspaceReportFieldDeleteMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FIELD) { + setClipboardMessage(getWorkspaceUpdateFieldMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FEATURE_ENABLED) { + Clipboard.setString(getWorkspaceFeatureEnabledMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_IS_ATTENDEE_TRACKING_ENABLED) { + Clipboard.setString(getWorkspaceAttendeeTrackingUpdateMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_APPROVER) { + Clipboard.setString(getDefaultApproverUpdateMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_SUBMITS_TO) { + Clipboard.setString(getSubmitsToUpdateMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FORWARDS_TO) { + Clipboard.setString(getForwardsToUpdateMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_PAY_APPROVED_REPORTS_ENABLED) { + Clipboard.setString(getAutoPayApprovedReportsEnabledMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT) { + Clipboard.setString(getAutoReimbursementMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_NAME) { + Clipboard.setString(getInvoiceCompanyNameUpdateMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_WEBSITE) { + Clipboard.setString(getInvoiceCompanyWebsiteUpdateMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSER) { + Clipboard.setString(getReimburserUpdateMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_ENABLED) { + Clipboard.setString(getWorkspaceReimbursementUpdateMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_ACH_ACCOUNT) { + Clipboard.setString(getUpdateACHAccountMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_ADDRESS) { + Clipboard.setString(getCompanyAddressUpdateMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT) { + Clipboard.setString(getPolicyChangeLogMaxExpenseAmountNoReceiptMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT) { + Clipboard.setString(getPolicyChangeLogMaxExpenseAmountMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AGE) { + Clipboard.setString(getPolicyChangeLogMaxExpenseAgeMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE) { + Clipboard.setString(getPolicyChangeLogDefaultBillableMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_REIMBURSABLE) { + Clipboard.setString(getPolicyChangeLogDefaultReimbursableMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED) { + Clipboard.setString(getPolicyChangeLogDefaultTitleEnforcedMessage(translate, reportAction)); + } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OWNERSHIP) { + setClipboardMessage(Parser.htmlToText(getUpdatedOwnershipMessage(translate, reportAction, policy) ?? '')); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { + setClipboardMessage(getUnreportedTransactionMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) { + Clipboard.setString(getMarkedReimbursedMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REIMBURSED)) { + Clipboard.setString(getReportActionMessageFragments(translate, reportAction).at(0)?.text ?? ''); + } else if (isReimbursementQueuedAction(reportAction)) { + Clipboard.setString(getReimbursementQueuedActionMessage({reportAction, translate, formatPhoneNumber: formatPhoneNumberPhoneUtils, report, shouldUseShortDisplayName: false})); + } else if (isActionableMentionWhisper(reportAction)) { + const mentionWhisperMessage = getActionableMentionWhisperMessage(translate, reportAction); + setClipboardMessage(mentionWhisperMessage); + } else if (isActionableTrackExpense(reportAction)) { + setClipboardMessage(CONST.ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE); + } else if (isRenamedAction(reportAction)) { + setClipboardMessage(getRenamedAction(translate, reportAction, isExpenseReport(report))); + } else if ( + isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) || + isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED) || + isMarkAsClosedAction(reportAction) + ) { + const harvesting = !isMarkAsClosedAction(reportAction) ? (getOriginalMessage(reportAction)?.harvesting ?? false) : false; + if (harvesting) { + setClipboardMessage(translate('iou.automaticallySubmitted')); + } else { + Clipboard.setString(translate('iou.submitted', getOriginalMessage(reportAction)?.message)); + } + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.APPROVED)) { + const {automaticAction} = getOriginalMessage(reportAction) ?? {}; + if (automaticAction) { + setClipboardMessage(translate('iou.automaticallyApproved')); + } else { + Clipboard.setString(translate('iou.approvedMessage')); + } + } else if (isUnapprovedAction(reportAction)) { + Clipboard.setString(translate('iou.unapproved')); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED)) { + const {automaticAction} = getOriginalMessage(reportAction) ?? {}; + if (automaticAction) { + setClipboardMessage(translate('iou.automaticallyForwarded')); + } else { + Clipboard.setString(translate('iou.forwarded')); + } + } else if (isRejectedAction(reportAction)) { + Clipboard.setString(translate('iou.rejectedThisReport')); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_UPGRADE) { + const displayMessage = translate('workspaceActions.upgradedWorkspace'); + Clipboard.setString(displayMessage); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_FORCE_UPGRADE) { + const displayMessage = Parser.htmlToText(translate('workspaceActions.forcedCorporateUpgrade')); + Clipboard.setString(displayMessage); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.TEAM_DOWNGRADE) { + Clipboard.setString(translate('workspaceActions.downgradedWorkspace')); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) { + Clipboard.setString(translate('iou.heldExpense')); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { + Clipboard.setString(translate('iou.unheldExpense')); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REJECTEDTRANSACTION_THREAD) { + Clipboard.setString(translate('iou.reject.reportActions.rejectedExpense')); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REJECTED_TRANSACTION_MARKASRESOLVED) { + Clipboard.setString(translate('iou.reject.reportActions.markedAsResolved')); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RETRACTED) { + Clipboard.setString(translate('iou.retracted')); + } else if (isOldDotReportAction(reportAction)) { + const oldDotActionMessage = getMessageOfOldDotReportAction(translate, reportAction); + Clipboard.setString(oldDotActionMessage); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION) { + const originalMessage = getOriginalMessage(reportAction) as ReportAction['originalMessage']; + Clipboard.setString(getDismissedViolationMessageText(translate, originalMessage)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES) { + Clipboard.setString(translate('violations.resolvedDuplicates')); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) { + setClipboardMessage(getExportIntegrationMessageHTML(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_DESCRIPTION) { + setClipboardMessage(getUpdateRoomDescriptionMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.UPDATE_ROOM_AVATAR) { + setClipboardMessage(getRoomAvatarUpdatedMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EMPLOYEE) { + setClipboardMessage(getPolicyChangeLogAddEmployeeMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE) { + setClipboardMessage(getPolicyChangeLogUpdateEmployee(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE) { + setClipboardMessage(getPolicyChangeLogDeleteMemberMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION) { + setClipboardMessage(getDeletedTransactionMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REOPENED) { + setClipboardMessage(translate('iou.reopened')); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED)) { + setClipboardMessage(getIntegrationSyncFailedMessage(translate, reportAction, report?.policyID, isTryNewDotNVPDismissed)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.COMPANY_CARD_CONNECTION_BROKEN)) { + setClipboardMessage(getCompanyCardConnectionBrokenMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE)) { + setClipboardMessage(getPlaidBalanceFailureMessage(translate, reportAction)); + } else if (isCardIssuedAction(reportAction)) { + const shouldNavigateToCardDetails = isPolicyAdmin(policy, currentUserPersonalDetails.login); + setClipboardMessage(getCardIssuedMessage({reportAction, shouldRenderHTML: true, shouldNavigateToCardDetails, policyID: report?.policyID, expensifyCard: card, translate})); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_INTEGRATION)) { + setClipboardMessage(getAddedConnectionMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION)) { + setClipboardMessage(getRemovedConnectionMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CARD_FEED)) { + setClipboardMessage(getAddedCardFeedMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CARD_FEED)) { + setClipboardMessage(getRemovedCardFeedMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.RENAME_CARD_FEED)) { + setClipboardMessage(getRenamedCardFeedMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ASSIGN_COMPANY_CARD)) { + setClipboardMessage(getAssignedCompanyCardMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UNASSIGN_COMPANY_CARD)) { + setClipboardMessage(getUnassignedCompanyCardMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CARD_FEED_LIABILITY)) { + setClipboardMessage(getUpdatedCardFeedLiabilityMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CARD_FEED_STATEMENT_PERIOD)) { + setClipboardMessage(getUpdatedCardFeedStatementPeriodMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.TRAVEL_UPDATE)) { + setClipboardMessage(getTravelUpdateMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUDIT_RATE)) { + setClipboardMessage(getUpdatedAuditRateMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_APPROVER_RULE)) { + setClipboardMessage(getAddedApprovalRuleMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_APPROVER_RULE)) { + setClipboardMessage(getDeletedApprovalRuleMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_APPROVER_RULE)) { + setClipboardMessage(getUpdatedApprovalRuleMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MANUAL_APPROVAL_THRESHOLD)) { + setClipboardMessage(getUpdatedManualApprovalThresholdMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_BUDGET)) { + setClipboardMessage(getAddedBudgetMessage(translate, reportAction, policy)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_BUDGET)) { + setClipboardMessage(getUpdatedBudgetMessage(translate, reportAction, policy)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_BUDGET)) { + setClipboardMessage(getDeletedBudgetMessage(translate, reportAction, policy)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TIME_ENABLED)) { + setClipboardMessage(getUpdatedTimeEnabledMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TIME_RATE)) { + setClipboardMessage(getUpdatedTimeRateMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_PROHIBITED_EXPENSES)) { + setClipboardMessage(getUpdatedProhibitedExpensesMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_CHOICE)) { + setClipboardMessage(getUpdatedReimbursementChoiceMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_AUTO_JOIN)) { + setClipboardMessage(getSetAutoJoinMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE)) { + setClipboardMessage(getUpdatedDefaultTitleMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_HARVESTING)) { + setClipboardMessage(getUpdatedAutoHarvestingMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INDIVIDUAL_BUDGET_NOTIFICATION)) { + setClipboardMessage(getUpdatedIndividualBudgetNotificationMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SHARED_BUDGET_NOTIFICATION)) { + setClipboardMessage(getUpdatedSharedBudgetNotificationMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.TAKE_CONTROL) || isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REROUTE)) { + setClipboardMessage(getChangedApproverActionMessage(translate, reportAction)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION)) { + setClipboardMessage(getMovedTransactionMessage(translate, reportAction, conciergeReportID)); + } else if (isMovedAction(reportAction)) { + setClipboardMessage(getMovedActionMessage(translate, reportAction, originalReport)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT)) { + setClipboardMessage(getActionableCardFraudAlertMessage(translate, reportAction, getLocalDateFromDatetime)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL)) { + setClipboardMessage(getActionableCard3DSTransactionApprovalMessage(translate, reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY) { + const displayMessage = getPolicyChangeMessage(translate, reportAction); + Clipboard.setString(displayMessage); + } else if (isActionableJoinRequest(reportAction)) { + const displayMessage = getJoinRequestMessage(translate, policy, reportAction); + Clipboard.setString(displayMessage); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.LEAVE_ROOM || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM) { + Clipboard.setString(translate('report.actions.type.leftTheChat')); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED)) { + setClipboardMessage(getDynamicExternalWorkflowRoutedMessage(reportAction, translate)); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CREATED) && isHarvestReport) { + const harvestReportName = getReportName(harvestReport); + const displayMessage = getHarvestCreatedExpenseReportMessage(harvestReport?.reportID, harvestReportName, translate); + setClipboardMessage(displayMessage); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.CREATED_REPORT_FOR_UNAPPROVED_TRANSACTIONS)) { + const {originalID} = getOriginalMessage(reportAction) ?? {}; + const originalReportOfUnapprovedTransaction = getReportOrDraftReport(originalID); + const reportName = getReportName(originalReportOfUnapprovedTransaction); + const displayMessage = getCreatedReportForUnapprovedTransactionsMessage( + originalID, + reportName, + isOriginalReportDeleted(reportAction, originalReportOfUnapprovedTransaction), + translate, + ); + setClipboardMessage(displayMessage); + } else if (isDynamicExternalWorkflowSubmitFailedAction(reportAction)) { + setClipboardMessage(getDynamicExternalWorkflowSubmitFailedActionMessage(translate, reportAction)); + } else if (isDynamicExternalWorkflowApproveFailedAction(reportAction)) { + setClipboardMessage(getDynamicExternalWorkflowApproveFailedActionMessage(translate, reportAction)); + } else if (content) { + setClipboardMessage( + content.replaceAll(/()(.*?)(<\/mention-user>)/gi, (match, openTag: string, innerContent: string, closeTag: string): string => { + const modifiedContent = Str.removeSMSDomain(innerContent) || ''; + return openTag + modifiedContent + closeTag || ''; + }), + ); + } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED)) { + setClipboardMessage(getSettlementAccountLockedMessage(translate, reportAction)); + } else if (messageText) { + Clipboard.setString(messageText); + } + } +} + +export type {CopyMessageClipboardParams}; +export {shouldShowCopyMessageAction, copyMessageToClipboard}; diff --git a/src/pages/inbox/report/ContextMenu/actions/CopyOnyxDataAction.tsx b/src/pages/inbox/report/ContextMenu/actions/CopyOnyxDataAction.tsx new file mode 100644 index 000000000000..8458ee6c3bca --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/CopyOnyxDataAction.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import Clipboard from '@libs/Clipboard'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {Report} from '@src/types/onyx'; + +type PopoverCopyOnyxDataItemProps = { + report: OnyxEntry; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +function PopoverCopyOnyxDataItem({report, isFocused, onFocus, onBlur}: PopoverCopyOnyxDataItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Copy', 'Checkmark'] as const); + + return ( + + interceptAnonymousUser(() => { + Clipboard.setString(JSON.stringify(report, null, 4)); + hideContextMenu(true, ReportActionComposeFocusManager.focus); + }, true) + } + isAnonymousAction + isFocused={isFocused} + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_ONYX_DATA} + /> + ); +} + +function shouldShowCopyOnyxDataAction({isProduction}: {isProduction: boolean}): boolean { + return !isProduction; +} + +export {shouldShowCopyOnyxDataAction, PopoverCopyOnyxDataItem}; diff --git a/src/pages/inbox/report/ContextMenu/actions/DebugAction.tsx b/src/pages/inbox/report/ContextMenu/actions/DebugAction.tsx new file mode 100644 index 000000000000..cce251823750 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/DebugAction.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import Navigation from '@libs/Navigation/Navigation'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {ReportAction} from '@src/types/onyx'; + +type PopoverDebugItemProps = { + reportID: string | undefined; + reportAction: ReportAction; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +function PopoverDebugItem({reportID, reportAction, isFocused, onFocus, onBlur}: PopoverDebugItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Bug'] as const); + + return ( + + interceptAnonymousUser(() => { + if (!reportID) { + return; + } + if (reportAction) { + Navigation.navigate(ROUTES.DEBUG_REPORT_ACTION.getRoute(reportID, reportAction.reportActionID)); + } else { + Navigation.navigate(ROUTES.DEBUG_REPORT.getRoute(reportID)); + } + hideContextMenu(false, ReportActionComposeFocusManager.focus); + }, true) + } + isAnonymousAction + isFocused={isFocused} + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.DEBUG} + /> + ); +} + +function shouldShowDebugAction({isDebugModeEnabled}: {isDebugModeEnabled: OnyxEntry}): boolean { + return !!isDebugModeEnabled; +} + +export {shouldShowDebugAction, PopoverDebugItem}; diff --git a/src/pages/inbox/report/ContextMenu/actions/DeleteAction/MiniDeleteItem.tsx b/src/pages/inbox/report/ContextMenu/actions/DeleteAction/MiniDeleteItem.tsx new file mode 100644 index 000000000000..53bdeb8ab679 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/DeleteAction/MiniDeleteItem.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {showDeleteModal} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +type MiniDeleteItemProps = { + reportID: string | undefined; + reportAction: ReportAction; + moneyRequestAction: ReportAction | undefined; + hideAndRun: (callback?: () => void) => void; +}; + +export default function MiniDeleteItem({reportID, reportAction, moneyRequestAction, hideAndRun}: MiniDeleteItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Trashcan'] as const); + + return ( + + interceptAnonymousUser(() => { + const iouReportID = isMoneyRequestAction(moneyRequestAction) ? getOriginalMessage(moneyRequestAction)?.IOUReportID : undefined; + const effectiveReportID = iouReportID && Number(iouReportID) !== 0 ? iouReportID : reportID; + const actionSourceID = effectiveReportID !== reportID ? reportID : undefined; + hideAndRun(() => showDeleteModal(effectiveReportID, moneyRequestAction ?? reportAction, undefined, undefined, undefined, actionSourceID)); + }) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.DELETE} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/DeleteAction/PopoverDeleteItem.tsx b/src/pages/inbox/report/ContextMenu/actions/DeleteAction/PopoverDeleteItem.tsx new file mode 100644 index 000000000000..b6f1da560dd7 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/DeleteAction/PopoverDeleteItem.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import FocusableMenuItem from '@components/FocusableMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {showDeleteModal} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +type PopoverDeleteItemProps = { + reportID: string | undefined; + reportAction: ReportAction; + moneyRequestAction: ReportAction | undefined; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverDeleteItem({reportID, reportAction, moneyRequestAction, hideAndRun, isFocused, onFocus, onBlur}: PopoverDeleteItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Trashcan'] as const); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowWidth} = useWindowDimensions(); + + return ( + + interceptAnonymousUser(() => { + const iouReportID = isMoneyRequestAction(moneyRequestAction) ? getOriginalMessage(moneyRequestAction)?.IOUReportID : undefined; + const effectiveReportID = iouReportID && Number(iouReportID) !== 0 ? iouReportID : reportID; + const actionSourceID = effectiveReportID !== reportID ? reportID : undefined; + hideAndRun(() => showDeleteModal(effectiveReportID, moneyRequestAction ?? reportAction, undefined, undefined, undefined, actionSourceID)); + }) + } + wrapperStyle={[styles.pr8]} + style={StyleUtils.getContextMenuItemStyles(windowWidth)} + focused={isFocused} + interactive + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.DELETE} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/DeleteAction/deleteAction.ts b/src/pages/inbox/report/ContextMenu/actions/DeleteAction/deleteAction.ts new file mode 100644 index 000000000000..f7dc179da2c0 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/DeleteAction/deleteAction.ts @@ -0,0 +1,41 @@ +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {getOriginalMessage, isMessageDeleted, isMoneyRequestAction, isReportPreviewAction} from '@libs/ReportActionsUtils'; +import {canDeleteReportAction} from '@libs/ReportUtils'; +import type {ReportAction, Transaction} from '@src/types/onyx'; + +function shouldShowDeleteAction({ + reportAction, + isArchivedRoom, + isChronosReport, + reportID, + moneyRequestAction, + iouTransaction, + transactions, + childReportActions, +}: { + reportAction: OnyxEntry; + isArchivedRoom: boolean; + isChronosReport: boolean; + reportID: string | undefined; + moneyRequestAction: ReportAction | undefined; + iouTransaction: OnyxEntry; + transactions: OnyxCollection | undefined; + childReportActions: OnyxCollection; +}): boolean { + let effectiveReportID: string | undefined = reportID; + if (isMoneyRequestAction(moneyRequestAction)) { + effectiveReportID = getOriginalMessage(moneyRequestAction)?.IOUReportID; + } else if (isReportPreviewAction(reportAction)) { + effectiveReportID = reportAction?.childReportID; + } + return ( + !!reportID && + canDeleteReportAction(moneyRequestAction ?? reportAction, effectiveReportID, iouTransaction, transactions, childReportActions) && + !isArchivedRoom && + !isChronosReport && + !isMessageDeleted(reportAction) + ); +} + +// eslint-disable-next-line import/prefer-default-export -- named utility export per module convention +export {shouldShowDeleteAction}; diff --git a/src/pages/inbox/report/ContextMenu/actions/DownloadAction/MiniDownloadItem.tsx b/src/pages/inbox/report/ContextMenu/actions/DownloadAction/MiniDownloadItem.tsx new file mode 100644 index 000000000000..cfc701bed0c6 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/DownloadAction/MiniDownloadItem.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import {isMobileSafari} from '@libs/Browser'; +import fileDownload from '@libs/fileDownload'; +import getAttachmentDetails from '@libs/fileDownload/getAttachmentDetails'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import {setDownload} from '@userActions/Download'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; +// eslint-disable-next-line @dword-design/import-alias/prefer-alias -- subdirectory relative to actions/actionConfig +import {getActionHtml} from '../actionConfig'; + +type MiniDownloadItemProps = { + reportAction: ReportAction; + encryptedAuthToken: string; +}; + +export default function MiniDownloadItem({reportAction, encryptedAuthToken}: MiniDownloadItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Download'] as const); + + return ( + + interceptAnonymousUser(() => { + const html = getActionHtml(reportAction); + const {originalFileName, sourceURL} = getAttachmentDetails(html); + const sourceURLWithAuth = addEncryptedAuthTokenToURL(sourceURL ?? '', encryptedAuthToken); + const sourceID = (sourceURL?.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_SOURCE_ID) ?? [])[1]; + setDownload(sourceID, true); + const anchorRegex = CONST.REGEX_LINK_IN_ANCHOR; + const isAnchorTag = anchorRegex.test(html); + fileDownload(translate, sourceURLWithAuth, originalFileName ?? '', '', isAnchorTag && isMobileSafari()).then(() => setDownload(sourceID, false)); + hideContextMenu(true, ReportActionComposeFocusManager.focus); + }, true) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.DOWNLOAD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/DownloadAction/PopoverDownloadItem.tsx b/src/pages/inbox/report/ContextMenu/actions/DownloadAction/PopoverDownloadItem.tsx new file mode 100644 index 000000000000..2e19ee65c12d --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/DownloadAction/PopoverDownloadItem.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import {isMobileSafari} from '@libs/Browser'; +import fileDownload from '@libs/fileDownload'; +import getAttachmentDetails from '@libs/fileDownload/getAttachmentDetails'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import {setDownload} from '@userActions/Download'; +import CONST from '@src/CONST'; +import type {Download as DownloadOnyx, ReportAction} from '@src/types/onyx'; +// eslint-disable-next-line @dword-design/import-alias/prefer-alias -- subdirectory relative to actions/actionConfig +import {getActionHtml} from '../actionConfig'; + +type PopoverDownloadItemProps = { + reportAction: ReportAction; + encryptedAuthToken: string; + download: OnyxEntry; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverDownloadItem({reportAction, encryptedAuthToken, download, isFocused, onFocus, onBlur}: PopoverDownloadItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Download'] as const); + const isDownloading = download?.isDownloading ?? false; + + return ( + + interceptAnonymousUser(() => { + const html = getActionHtml(reportAction); + const {originalFileName, sourceURL} = getAttachmentDetails(html); + const sourceURLWithAuth = addEncryptedAuthTokenToURL(sourceURL ?? '', encryptedAuthToken); + const sourceID = (sourceURL?.match(CONST.REGEX.ATTACHMENT.ATTACHMENT_SOURCE_ID) ?? [])[1]; + setDownload(sourceID, true); + const anchorRegex = CONST.REGEX_LINK_IN_ANCHOR; + const isAnchorTag = anchorRegex.test(html); + fileDownload(translate, sourceURLWithAuth, originalFileName ?? '', '', isAnchorTag && isMobileSafari()).then(() => setDownload(sourceID, false)); + hideContextMenu(true, ReportActionComposeFocusManager.focus); + }, true) + } + isAnonymousAction + disabled={isDownloading} + shouldShowLoadingSpinnerIcon={isDownloading} + isFocused={isFocused} + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.DOWNLOAD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/DownloadAction/downloadAction.ts b/src/pages/inbox/report/ContextMenu/actions/DownloadAction/downloadAction.ts new file mode 100644 index 000000000000..271df88c07c9 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/DownloadAction/downloadAction.ts @@ -0,0 +1,16 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {isMessageDeleted, isReportActionAttachment} from '@libs/ReportActionsUtils'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; +// eslint-disable-next-line @dword-design/import-alias/prefer-alias -- subdirectory relative to actions/actionConfig +import {getActionHtml} from '../actionConfig'; + +function shouldShowDownloadAction({reportAction, isOffline}: {reportAction: OnyxEntry; isOffline: boolean}): boolean { + const isAttachment = isReportActionAttachment(reportAction); + const html = getActionHtml(reportAction); + const isUploading = html.includes(CONST.ATTACHMENT_OPTIMISTIC_SOURCE_ATTRIBUTE); + return isAttachment && !isUploading && !!reportAction?.reportActionID && !isMessageDeleted(reportAction) && !isOffline; +} + +// eslint-disable-next-line import/prefer-default-export -- named utility export per module convention +export {shouldShowDownloadAction}; diff --git a/src/pages/inbox/report/ContextMenu/actions/EditAction/MiniEditItem.tsx b/src/pages/inbox/report/ContextMenu/actions/EditAction/MiniEditItem.tsx new file mode 100644 index 000000000000..556c9485f3ce --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/EditAction/MiniEditItem.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import Navigation from '@libs/Navigation/Navigation'; +import Parser from '@libs/Parser'; +import {isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getActionHtml} from '@pages/inbox/report/ContextMenu/actions/actionConfig'; +import {deleteReportActionDraft, openReport, saveReportActionDraft} from '@userActions/Report'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Beta, IntroSelected, ReportAction} from '@src/types/onyx'; + +type MiniEditItemProps = { + reportID: string | undefined; + reportAction: ReportAction; + moneyRequestAction: ReportAction | undefined; + draftMessage: string; + introSelected: OnyxEntry; + betas: OnyxEntry; + hideAndRun: (callback?: () => void) => void; +}; + +export default function MiniEditItem({reportID, reportAction, moneyRequestAction, draftMessage, introSelected, betas, hideAndRun}: MiniEditItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Pencil'] as const); + + return ( + + interceptAnonymousUser(() => { + if (isMoneyRequestAction(reportAction) || isMoneyRequestAction(moneyRequestAction)) { + hideAndRun(() => { + const childReportID = reportAction?.childReportID; + openReport({reportID: childReportID, introSelected, betas}); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); + }); + return; + } + hideAndRun(() => { + if (!draftMessage) { + saveReportActionDraft(reportID, reportAction, Parser.htmlToMarkdown(getActionHtml(reportAction))); + } else { + deleteReportActionDraft(reportID, reportAction); + } + }); + }) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.EDIT_COMMENT} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/EditAction/PopoverEditItem.tsx b/src/pages/inbox/report/ContextMenu/actions/EditAction/PopoverEditItem.tsx new file mode 100644 index 000000000000..a08c27f9a11a --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/EditAction/PopoverEditItem.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import FocusableMenuItem from '@components/FocusableMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import Navigation from '@libs/Navigation/Navigation'; +import Parser from '@libs/Parser'; +import {isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getActionHtml} from '@pages/inbox/report/ContextMenu/actions/actionConfig'; +import {deleteReportActionDraft, openReport, saveReportActionDraft} from '@userActions/Report'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Beta, IntroSelected, ReportAction} from '@src/types/onyx'; + +type PopoverEditItemProps = { + reportID: string | undefined; + reportAction: ReportAction; + moneyRequestAction: ReportAction | undefined; + draftMessage: string; + introSelected: OnyxEntry; + betas: OnyxEntry; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverEditItem({reportID, reportAction, moneyRequestAction, draftMessage, introSelected, betas, hideAndRun, isFocused, onFocus, onBlur}: PopoverEditItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Pencil'] as const); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowWidth} = useWindowDimensions(); + + return ( + + interceptAnonymousUser(() => { + if (isMoneyRequestAction(reportAction) || isMoneyRequestAction(moneyRequestAction)) { + hideAndRun(() => { + const childReportID = reportAction?.childReportID; + openReport({reportID: childReportID, introSelected, betas}); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); + }); + return; + } + hideAndRun(() => { + if (!draftMessage) { + saveReportActionDraft(reportID, reportAction, Parser.htmlToMarkdown(getActionHtml(reportAction))); + } else { + deleteReportActionDraft(reportID, reportAction); + } + }); + }) + } + wrapperStyle={[styles.pr8]} + style={StyleUtils.getContextMenuItemStyles(windowWidth)} + focused={isFocused} + interactive + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.EDIT_COMMENT} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/EditAction/editAction.ts b/src/pages/inbox/report/ContextMenu/actions/EditAction/editAction.ts new file mode 100644 index 000000000000..993d84fcb98e --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/EditAction/editAction.ts @@ -0,0 +1,22 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {canEditReportAction} from '@libs/ReportUtils'; +import type {ReportAction, Transaction} from '@src/types/onyx'; + +function shouldShowEditAction({ + reportAction, + isArchivedRoom, + isChronosReport, + moneyRequestAction, + iouTransaction, +}: { + reportAction: OnyxEntry; + isArchivedRoom: boolean; + isChronosReport: boolean; + moneyRequestAction: ReportAction | undefined; + iouTransaction: OnyxEntry; +}): boolean { + return (canEditReportAction(reportAction, iouTransaction) || canEditReportAction(moneyRequestAction, iouTransaction)) && !isArchivedRoom && !isChronosReport; +} + +// eslint-disable-next-line import/prefer-default-export -- named utility export per module convention +export {shouldShowEditAction}; diff --git a/src/pages/inbox/report/ContextMenu/actions/ExplainAction/MiniExplainItem.tsx b/src/pages/inbox/report/ContextMenu/actions/ExplainAction/MiniExplainItem.tsx new file mode 100644 index 000000000000..20d9560d6798 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/ExplainAction/MiniExplainItem.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import type useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {explain} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {Beta, IntroSelected, ReportAction, Report as ReportType} from '@src/types/onyx'; +import KeyboardUtils from '@src/utils/keyboard'; + +type MiniExplainItemProps = { + childReport: OnyxEntry; + originalReport: OnyxEntry; + reportAction: ReportAction; + currentUserPersonalDetails: ReturnType; + introSelected: OnyxEntry; + betas: OnyxEntry; + hideAndRun: (callback?: () => void) => void; +}; + +export default function MiniExplainItem({childReport, originalReport, reportAction, currentUserPersonalDetails, introSelected, betas, hideAndRun}: MiniExplainItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Concierge'] as const); + + return ( + + interceptAnonymousUser(() => { + if (!originalReport?.reportID) { + return; + } + hideAndRun(() => { + KeyboardUtils.dismiss().then(() => + explain( + childReport, + originalReport, + reportAction, + translate, + currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, + introSelected, + betas, + currentUserPersonalDetails?.timezone, + ), + ); + }); + }) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.EXPLAIN} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/ExplainAction/PopoverExplainItem.tsx b/src/pages/inbox/report/ContextMenu/actions/ExplainAction/PopoverExplainItem.tsx new file mode 100644 index 000000000000..270318b4f88d --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/ExplainAction/PopoverExplainItem.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import FocusableMenuItem from '@components/FocusableMenuItem'; +import type useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {explain} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {Beta, IntroSelected, ReportAction, Report as ReportType} from '@src/types/onyx'; +import KeyboardUtils from '@src/utils/keyboard'; + +type PopoverExplainItemProps = { + childReport: OnyxEntry; + originalReport: OnyxEntry; + reportAction: ReportAction; + currentUserPersonalDetails: ReturnType; + introSelected: OnyxEntry; + betas: OnyxEntry; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverExplainItem({ + childReport, + originalReport, + reportAction, + currentUserPersonalDetails, + introSelected, + betas, + hideAndRun, + isFocused, + onFocus, + onBlur, +}: PopoverExplainItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Concierge'] as const); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowWidth} = useWindowDimensions(); + + return ( + + interceptAnonymousUser(() => { + if (!originalReport?.reportID) { + return; + } + hideAndRun(() => { + KeyboardUtils.dismiss().then(() => + explain( + childReport, + originalReport, + reportAction, + translate, + currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, + introSelected, + betas, + currentUserPersonalDetails?.timezone, + ), + ); + }); + }) + } + wrapperStyle={[styles.pr8]} + style={StyleUtils.getContextMenuItemStyles(windowWidth)} + focused={isFocused} + interactive + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.EXPLAIN} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/ExplainAction/explainAction.ts b/src/pages/inbox/report/ContextMenu/actions/ExplainAction/explainAction.ts new file mode 100644 index 000000000000..1aaaf1ffa53e --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/ExplainAction/explainAction.ts @@ -0,0 +1,13 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {hasReasoning} from '@libs/ReportActionsUtils'; +import type {ReportAction} from '@src/types/onyx'; + +function shouldShowExplainAction({reportAction, isArchivedRoom}: {reportAction: OnyxEntry; isArchivedRoom: boolean}): boolean { + if (isArchivedRoom || !reportAction) { + return false; + } + return hasReasoning(reportAction); +} + +// eslint-disable-next-line import/prefer-default-export -- named utility export per module convention +export {shouldShowExplainAction}; diff --git a/src/pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/MiniFlagAsOffensiveItem.tsx b/src/pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/MiniFlagAsOffensiveItem.tsx new file mode 100644 index 000000000000..9c9f5beb7f1f --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/MiniFlagAsOffensiveItem.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {ReportAction} from '@src/types/onyx'; +import KeyboardUtils from '@src/utils/keyboard'; + +type MiniFlagAsOffensiveItemProps = { + originalReportID: string | undefined; + reportAction: ReportAction; + hideAndRun: (callback?: () => void) => void; +}; + +export default function MiniFlagAsOffensiveItem({originalReportID, reportAction, hideAndRun}: MiniFlagAsOffensiveItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Flag'] as const); + + return ( + + interceptAnonymousUser(() => { + if (!originalReportID) { + return; + } + hideAndRun(() => { + KeyboardUtils.dismiss().then(() => { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.FLAG_COMMENT.getRoute(originalReportID, reportAction.reportActionID))); + }); + }); + }) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.FLAG_AS_OFFENSIVE} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/PopoverFlagAsOffensiveItem.tsx b/src/pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/PopoverFlagAsOffensiveItem.tsx new file mode 100644 index 000000000000..c30d66671e46 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/PopoverFlagAsOffensiveItem.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import FocusableMenuItem from '@components/FocusableMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {ReportAction} from '@src/types/onyx'; +import KeyboardUtils from '@src/utils/keyboard'; + +type PopoverFlagAsOffensiveItemProps = { + originalReportID: string | undefined; + reportAction: ReportAction; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverFlagAsOffensiveItem({originalReportID, reportAction, hideAndRun, isFocused, onFocus, onBlur}: PopoverFlagAsOffensiveItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Flag'] as const); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowWidth} = useWindowDimensions(); + + return ( + + interceptAnonymousUser(() => { + if (!originalReportID) { + return; + } + hideAndRun(() => { + KeyboardUtils.dismiss().then(() => { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.FLAG_COMMENT.getRoute(originalReportID, reportAction.reportActionID))); + }); + }); + }) + } + wrapperStyle={[styles.pr8]} + style={StyleUtils.getContextMenuItemStyles(windowWidth)} + focused={isFocused} + interactive + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.FLAG_AS_OFFENSIVE} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/flagAsOffensiveAction.ts b/src/pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/flagAsOffensiveAction.ts new file mode 100644 index 000000000000..d778ea078d99 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/FlagAsOffensiveAction/flagAsOffensiveAction.ts @@ -0,0 +1,21 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {canFlagReportAction} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +function shouldShowFlagAsOffensiveAction({ + reportAction, + isArchivedRoom, + isChronosReport, + reportID, +}: { + reportAction: OnyxEntry; + isArchivedRoom: boolean; + isChronosReport: boolean; + reportID: string | undefined; +}): boolean { + return canFlagReportAction(reportAction, reportID) && !isArchivedRoom && !isChronosReport && reportAction?.actorAccountID !== CONST.ACCOUNT_ID.CONCIERGE; +} + +// eslint-disable-next-line import/prefer-default-export -- named utility export per module convention +export {shouldShowFlagAsOffensiveAction}; diff --git a/src/pages/inbox/report/ContextMenu/actions/HoldAction/MiniHoldItem.tsx b/src/pages/inbox/report/ContextMenu/actions/HoldAction/MiniHoldItem.tsx new file mode 100644 index 000000000000..281cf2932668 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/HoldAction/MiniHoldItem.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {changeMoneyRequestHoldStatus} from '@libs/ReportUtils'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {ReportAction, Transaction} from '@src/types/onyx'; + +type MiniHoldItemProps = { + moneyRequestAction: ReportAction | undefined; + iouTransaction: OnyxEntry; + isOffline: boolean; + isDelegateAccessRestricted: boolean; + showDelegateNoAccessModal: (() => void) | undefined; + hideAndRun: (callback?: () => void) => void; +}; + +export default function MiniHoldItem({moneyRequestAction, iouTransaction, isOffline, isDelegateAccessRestricted, showDelegateNoAccessModal, hideAndRun}: MiniHoldItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Stopwatch'] as const); + + return ( + + interceptAnonymousUser(() => { + if (isDelegateAccessRestricted) { + hideContextMenu(false, showDelegateNoAccessModal); + return; + } + hideAndRun(() => changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction, isOffline)); + }, false) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.HOLD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/HoldAction/PopoverHoldItem.tsx b/src/pages/inbox/report/ContextMenu/actions/HoldAction/PopoverHoldItem.tsx new file mode 100644 index 000000000000..e51f4ae18f85 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/HoldAction/PopoverHoldItem.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import FocusableMenuItem from '@components/FocusableMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {changeMoneyRequestHoldStatus} from '@libs/ReportUtils'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {ReportAction, Transaction} from '@src/types/onyx'; + +type PopoverHoldItemProps = { + moneyRequestAction: ReportAction | undefined; + iouTransaction: OnyxEntry; + isOffline: boolean; + isDelegateAccessRestricted: boolean; + showDelegateNoAccessModal: (() => void) | undefined; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverHoldItem({ + moneyRequestAction, + iouTransaction, + isOffline, + isDelegateAccessRestricted, + showDelegateNoAccessModal, + hideAndRun, + isFocused, + onFocus, + onBlur, +}: PopoverHoldItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Stopwatch'] as const); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowWidth} = useWindowDimensions(); + + return ( + + interceptAnonymousUser(() => { + if (isDelegateAccessRestricted) { + hideContextMenu(false, showDelegateNoAccessModal); + return; + } + hideAndRun(() => changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction, isOffline)); + }, false) + } + wrapperStyle={[styles.pr8]} + style={StyleUtils.getContextMenuItemStyles(windowWidth)} + focused={isFocused} + interactive + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.HOLD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/HoldAction/holdAction.ts b/src/pages/inbox/report/ContextMenu/actions/HoldAction/holdAction.ts new file mode 100644 index 000000000000..da8de2bf1788 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/HoldAction/holdAction.ts @@ -0,0 +1,29 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {getReportAction} from '@libs/ReportActionsUtils'; +import {canHoldUnholdReportAction} from '@libs/ReportUtils'; +import type {Policy, ReportAction, Report as ReportType, Transaction} from '@src/types/onyx'; + +function shouldShowHoldAction({ + moneyRequestReport, + moneyRequestAction, + moneyRequestPolicy, + areHoldRequirementsMet, + iouTransaction, + currentUserAccountID, +}: { + moneyRequestReport: OnyxEntry; + moneyRequestAction: ReportAction | undefined; + moneyRequestPolicy: OnyxEntry; + areHoldRequirementsMet: boolean; + iouTransaction: OnyxEntry; + currentUserAccountID: number; +}): boolean { + if (!areHoldRequirementsMet) { + return false; + } + const holdReportAction = getReportAction(moneyRequestAction?.childReportID, `${iouTransaction?.comment?.hold ?? ''}`); + return canHoldUnholdReportAction(moneyRequestReport, moneyRequestAction, holdReportAction, iouTransaction, moneyRequestPolicy, currentUserAccountID).canHoldRequest; +} + +// eslint-disable-next-line import/prefer-default-export -- named utility export per module convention +export {shouldShowHoldAction}; diff --git a/src/pages/inbox/report/ContextMenu/actions/JoinThreadAction/MiniJoinThreadItem.tsx b/src/pages/inbox/report/ContextMenu/actions/JoinThreadAction/MiniJoinThreadItem.tsx new file mode 100644 index 000000000000..3158499ccad0 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/JoinThreadAction/MiniJoinThreadItem.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {getChildReportNotificationPreference} from '@libs/ReportUtils'; +import {toggleSubscribeToChildReport} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {Beta, IntroSelected, ReportAction, Report as ReportType} from '@src/types/onyx'; + +type MiniJoinThreadItemProps = { + reportAction: ReportAction; + originalReport: OnyxEntry; + currentUserAccountID: number; + introSelected: OnyxEntry; + isSelfTourViewed: boolean | undefined; + betas: OnyxEntry; + hideAndRun: (callback?: () => void) => void; +}; + +export default function MiniJoinThreadItem({reportAction, originalReport, currentUserAccountID, introSelected, isSelfTourViewed, betas, hideAndRun}: MiniJoinThreadItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Bell'] as const); + + return ( + + interceptAnonymousUser(() => { + const childReportNotificationPreference = getChildReportNotificationPreference(reportAction); + hideAndRun(() => { + ReportActionComposeFocusManager.focus(); + toggleSubscribeToChildReport( + reportAction?.childReportID, + currentUserAccountID, + reportAction, + originalReport, + introSelected, + isSelfTourViewed, + betas, + childReportNotificationPreference, + ); + }); + }, false) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.JOIN_THREAD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/JoinThreadAction/PopoverJoinThreadItem.tsx b/src/pages/inbox/report/ContextMenu/actions/JoinThreadAction/PopoverJoinThreadItem.tsx new file mode 100644 index 000000000000..9b478aa533f6 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/JoinThreadAction/PopoverJoinThreadItem.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import FocusableMenuItem from '@components/FocusableMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {getChildReportNotificationPreference} from '@libs/ReportUtils'; +import {toggleSubscribeToChildReport} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {Beta, IntroSelected, ReportAction, Report as ReportType} from '@src/types/onyx'; + +type PopoverJoinThreadItemProps = { + reportAction: ReportAction; + originalReport: OnyxEntry; + currentUserAccountID: number; + introSelected: OnyxEntry; + isSelfTourViewed: boolean | undefined; + betas: OnyxEntry; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverJoinThreadItem({ + reportAction, + originalReport, + currentUserAccountID, + introSelected, + isSelfTourViewed, + betas, + hideAndRun, + isFocused, + onFocus, + onBlur, +}: PopoverJoinThreadItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Bell'] as const); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowWidth} = useWindowDimensions(); + + return ( + + interceptAnonymousUser(() => { + const childReportNotificationPreference = getChildReportNotificationPreference(reportAction); + hideAndRun(() => { + ReportActionComposeFocusManager.focus(); + toggleSubscribeToChildReport( + reportAction?.childReportID, + currentUserAccountID, + reportAction, + originalReport, + introSelected, + isSelfTourViewed, + betas, + childReportNotificationPreference, + ); + }); + }, false) + } + wrapperStyle={[styles.pr8]} + style={StyleUtils.getContextMenuItemStyles(windowWidth)} + focused={isFocused} + interactive + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.JOIN_THREAD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/JoinThreadAction/joinThreadAction.ts b/src/pages/inbox/report/ContextMenu/actions/JoinThreadAction/joinThreadAction.ts new file mode 100644 index 000000000000..3dead385cf32 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/JoinThreadAction/joinThreadAction.ts @@ -0,0 +1,39 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {isActionableTrackExpense, isCreatedAction, isCreatedTaskReportAction, isDeletedAction, isMoneyRequestAction, isReportPreviewAction, isWhisperAction} from '@libs/ReportActionsUtils'; +import {getChildReportNotificationPreference, shouldDisableThread, shouldDisplayThreadReplies} from '@libs/ReportUtils'; +import type {ReportAction} from '@src/types/onyx'; + +function shouldShowJoinThreadAction({ + reportAction, + isArchivedRoom, + isThreadReportParentAction, + isHarvestReport, +}: { + reportAction: OnyxEntry; + isArchivedRoom: boolean; + isThreadReportParentAction: boolean; + isHarvestReport: boolean; +}): boolean { + const childReportNotificationPreference = getChildReportNotificationPreference(reportAction); + const isDeletedActionResult = isDeletedAction(reportAction); + const shouldDisplayReplies = shouldDisplayThreadReplies(reportAction, isThreadReportParentAction); + const subscribed = childReportNotificationPreference !== 'hidden'; + const isWhisper = isWhisperAction(reportAction) || isActionableTrackExpense(reportAction); + const isExpenseReportAction = isMoneyRequestAction(reportAction) || isReportPreviewAction(reportAction); + const isTaskAction = isCreatedTaskReportAction(reportAction); + const isHarvestCreatedExpenseReportAction = !!isHarvestReport && isCreatedAction(reportAction); + const shouldDisableJoin = shouldDisableThread(reportAction, isThreadReportParentAction, isArchivedRoom); + return ( + !subscribed && + !isWhisper && + !isTaskAction && + !isExpenseReportAction && + !isThreadReportParentAction && + !isHarvestCreatedExpenseReportAction && + !shouldDisableJoin && + (shouldDisplayReplies || (!isDeletedActionResult && !isArchivedRoom)) + ); +} + +// eslint-disable-next-line import/prefer-default-export -- named utility export per module convention +export {shouldShowJoinThreadAction}; diff --git a/src/pages/inbox/report/ContextMenu/actions/LeaveThreadAction/MiniLeaveThreadItem.tsx b/src/pages/inbox/report/ContextMenu/actions/LeaveThreadAction/MiniLeaveThreadItem.tsx new file mode 100644 index 000000000000..5738dcc37b39 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/LeaveThreadAction/MiniLeaveThreadItem.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {getChildReportNotificationPreference} from '@libs/ReportUtils'; +import {toggleSubscribeToChildReport} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {Beta, IntroSelected, ReportAction, Report as ReportType} from '@src/types/onyx'; + +type MiniLeaveThreadItemProps = { + reportAction: ReportAction; + originalReport: OnyxEntry; + currentUserAccountID: number; + introSelected: OnyxEntry; + isSelfTourViewed: boolean | undefined; + betas: OnyxEntry; + hideAndRun: (callback?: () => void) => void; +}; + +export default function MiniLeaveThreadItem({reportAction, originalReport, currentUserAccountID, introSelected, isSelfTourViewed, betas, hideAndRun}: MiniLeaveThreadItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Exit'] as const); + + return ( + + interceptAnonymousUser(() => { + const childReportNotificationPreference = getChildReportNotificationPreference(reportAction); + hideAndRun(() => { + ReportActionComposeFocusManager.focus(); + toggleSubscribeToChildReport( + reportAction?.childReportID, + currentUserAccountID, + reportAction, + originalReport, + introSelected, + isSelfTourViewed, + betas, + childReportNotificationPreference, + ); + }); + }, false) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.LEAVE_THREAD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/LeaveThreadAction/PopoverLeaveThreadItem.tsx b/src/pages/inbox/report/ContextMenu/actions/LeaveThreadAction/PopoverLeaveThreadItem.tsx new file mode 100644 index 000000000000..1ba097b63484 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/LeaveThreadAction/PopoverLeaveThreadItem.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import FocusableMenuItem from '@components/FocusableMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {getChildReportNotificationPreference} from '@libs/ReportUtils'; +import {toggleSubscribeToChildReport} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {Beta, IntroSelected, ReportAction, Report as ReportType} from '@src/types/onyx'; + +type PopoverLeaveThreadItemProps = { + reportAction: ReportAction; + originalReport: OnyxEntry; + currentUserAccountID: number; + introSelected: OnyxEntry; + isSelfTourViewed: boolean | undefined; + betas: OnyxEntry; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverLeaveThreadItem({ + reportAction, + originalReport, + currentUserAccountID, + introSelected, + isSelfTourViewed, + betas, + hideAndRun, + isFocused, + onFocus, + onBlur, +}: PopoverLeaveThreadItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Exit'] as const); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowWidth} = useWindowDimensions(); + + return ( + + interceptAnonymousUser(() => { + const childReportNotificationPreference = getChildReportNotificationPreference(reportAction); + hideAndRun(() => { + ReportActionComposeFocusManager.focus(); + toggleSubscribeToChildReport( + reportAction?.childReportID, + currentUserAccountID, + reportAction, + originalReport, + introSelected, + isSelfTourViewed, + betas, + childReportNotificationPreference, + ); + }); + }, false) + } + wrapperStyle={[styles.pr8]} + style={StyleUtils.getContextMenuItemStyles(windowWidth)} + focused={isFocused} + interactive + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.LEAVE_THREAD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/LeaveThreadAction/leaveThreadAction.ts b/src/pages/inbox/report/ContextMenu/actions/LeaveThreadAction/leaveThreadAction.ts new file mode 100644 index 000000000000..eb313ccb7a07 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/LeaveThreadAction/leaveThreadAction.ts @@ -0,0 +1,37 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {isActionableTrackExpense, isCreatedAction, isCreatedTaskReportAction, isDeletedAction, isMoneyRequestAction, isReportPreviewAction, isWhisperAction} from '@libs/ReportActionsUtils'; +import {getChildReportNotificationPreference, shouldDisplayThreadReplies} from '@libs/ReportUtils'; +import type {ReportAction} from '@src/types/onyx'; + +function shouldShowLeaveThreadAction({ + reportAction, + isArchivedRoom, + isThreadReportParentAction, + isHarvestReport, +}: { + reportAction: OnyxEntry; + isArchivedRoom: boolean; + isThreadReportParentAction: boolean; + isHarvestReport: boolean; +}): boolean { + const childReportNotificationPreference = getChildReportNotificationPreference(reportAction); + const isDeletedActionResult = isDeletedAction(reportAction); + const shouldDisplayReplies = shouldDisplayThreadReplies(reportAction, isThreadReportParentAction); + const subscribed = childReportNotificationPreference !== 'hidden'; + const isWhisper = isWhisperAction(reportAction) || isActionableTrackExpense(reportAction); + const isExpenseReportAction = isMoneyRequestAction(reportAction) || isReportPreviewAction(reportAction); + const isTaskAction = isCreatedTaskReportAction(reportAction); + const isHarvestCreatedExpenseReportAction = !!isHarvestReport && isCreatedAction(reportAction); + return ( + subscribed && + !isWhisper && + !isTaskAction && + !isExpenseReportAction && + !isThreadReportParentAction && + !isHarvestCreatedExpenseReportAction && + (shouldDisplayReplies || (!isDeletedActionResult && !isArchivedRoom)) + ); +} + +// eslint-disable-next-line import/prefer-default-export -- named utility export per module convention +export {shouldShowLeaveThreadAction}; diff --git a/src/pages/inbox/report/ContextMenu/actions/MarkAsReadAction.tsx b/src/pages/inbox/report/ContextMenu/actions/MarkAsReadAction.tsx new file mode 100644 index 000000000000..102c22919b86 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/MarkAsReadAction.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {readNewestAction} from '@userActions/Report'; +import CONST from '@src/CONST'; + +type PopoverMarkAsReadItemProps = { + reportID: string | undefined; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +function PopoverMarkAsReadItem({reportID, hideAndRun, isFocused, onFocus, onBlur}: PopoverMarkAsReadItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Mail', 'Checkmark'] as const); + + return ( + + interceptAnonymousUser(() => { + readNewestAction(reportID, true, true); + hideAndRun(ReportActionComposeFocusManager.focus); + }) + } + isFocused={isFocused} + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.MARK_AS_READ} + /> + ); +} + +function shouldShowMarkAsReadAction({isUnreadChat}: {isUnreadChat: boolean}): boolean { + return isUnreadChat; +} + +export {shouldShowMarkAsReadAction, PopoverMarkAsReadItem}; diff --git a/src/pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/MiniMarkAsUnreadItem.tsx b/src/pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/MiniMarkAsUnreadItem.tsx new file mode 100644 index 000000000000..bfae9bd282c6 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/MiniMarkAsUnreadItem.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {markCommentAsUnread} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {ReportAction, ReportActions} from '@src/types/onyx'; + +type MiniMarkAsUnreadItemProps = { + reportID: string | undefined; + reportActions: OnyxEntry; + reportAction: ReportAction; + currentUserAccountID: number; + hideAndRun: (callback?: () => void) => void; +}; + +export default function MiniMarkAsUnreadItem({reportID, reportActions, reportAction, currentUserAccountID, hideAndRun}: MiniMarkAsUnreadItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['ChatBubbleUnread', 'Checkmark'] as const); + + return ( + + interceptAnonymousUser(() => { + markCommentAsUnread(reportID, reportActions, reportAction, currentUserAccountID); + hideAndRun(ReportActionComposeFocusManager.focus); + }) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.MARK_AS_UNREAD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/PopoverMarkAsUnreadItem.tsx b/src/pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/PopoverMarkAsUnreadItem.tsx new file mode 100644 index 000000000000..53857db1cf7e --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/PopoverMarkAsUnreadItem.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {markCommentAsUnread} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {ReportAction, ReportActions} from '@src/types/onyx'; + +type PopoverMarkAsUnreadItemProps = { + reportID: string | undefined; + reportActions: OnyxEntry; + reportAction?: ReportAction; + currentUserAccountID: number; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverMarkAsUnreadItem({reportID, reportActions, reportAction, currentUserAccountID, hideAndRun, isFocused, onFocus, onBlur}: PopoverMarkAsUnreadItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['ChatBubbleUnread', 'Checkmark'] as const); + + return ( + + interceptAnonymousUser(() => { + markCommentAsUnread(reportID, reportActions, reportAction, currentUserAccountID); + hideAndRun(ReportActionComposeFocusManager.focus); + }) + } + isFocused={isFocused} + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.MARK_AS_UNREAD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/markAsUnreadAction.ts b/src/pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/markAsUnreadAction.ts new file mode 100644 index 000000000000..b83f7ea53b12 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/MarkAsUnreadAction/markAsUnreadAction.ts @@ -0,0 +1,14 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {isActionOfType} from '@libs/ReportActionsUtils'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +function shouldShowMarkAsUnreadForReportAction({reportAction}: {reportAction: OnyxEntry}): boolean { + return !isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED); +} + +function shouldShowMarkAsUnreadForReport({isUnreadChat}: {isUnreadChat: boolean}): boolean { + return !isUnreadChat; +} + +export {shouldShowMarkAsUnreadForReportAction, shouldShowMarkAsUnreadForReport}; diff --git a/src/pages/inbox/report/ContextMenu/actions/PinAction.tsx b/src/pages/inbox/report/ContextMenu/actions/PinAction.tsx new file mode 100644 index 000000000000..42a3eb75cce8 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/PinAction.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {togglePinnedState} from '@userActions/Report'; +import CONST from '@src/CONST'; + +type PopoverPinItemProps = { + reportID: string | undefined; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +function PopoverPinItem({reportID, hideAndRun, isFocused, onFocus, onBlur}: PopoverPinItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Pin'] as const); + + return ( + + interceptAnonymousUser(() => { + togglePinnedState(reportID, false); + hideAndRun(ReportActionComposeFocusManager.focus); + }) + } + isFocused={isFocused} + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.PIN} + /> + ); +} + +function shouldShowPinAction({isPinnedChat}: {isPinnedChat: boolean}): boolean { + return !isPinnedChat; +} + +export {shouldShowPinAction, PopoverPinItem}; diff --git a/src/pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/MiniReplyInThreadItem.tsx b/src/pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/MiniReplyInThreadItem.tsx new file mode 100644 index 000000000000..a9e8b4b20738 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/MiniReplyInThreadItem.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {navigateToAndOpenChildReport} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {Beta, IntroSelected, ReportAction, Report as ReportType} from '@src/types/onyx'; +import KeyboardUtils from '@src/utils/keyboard'; + +type MiniReplyInThreadItemProps = { + childReport: OnyxEntry; + reportAction: ReportAction; + originalReport: OnyxEntry; + currentUserAccountID: number; + introSelected: OnyxEntry; + betas: OnyxEntry; + hideAndRun: (callback?: () => void) => void; +}; + +export default function MiniReplyInThreadItem({childReport, reportAction, originalReport, currentUserAccountID, introSelected, betas, hideAndRun}: MiniReplyInThreadItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['ChatBubbleReply'] as const); + + return ( + + interceptAnonymousUser(() => { + hideAndRun(() => { + KeyboardUtils.dismiss().then(() => navigateToAndOpenChildReport(childReport, reportAction, originalReport, currentUserAccountID, introSelected, betas)); + }); + }, false) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.REPLY_IN_THREAD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/PopoverReplyInThreadItem.tsx b/src/pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/PopoverReplyInThreadItem.tsx new file mode 100644 index 000000000000..817373b6736f --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/PopoverReplyInThreadItem.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import FocusableMenuItem from '@components/FocusableMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {navigateToAndOpenChildReport} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {Beta, IntroSelected, ReportAction, Report as ReportType} from '@src/types/onyx'; +import KeyboardUtils from '@src/utils/keyboard'; + +type PopoverReplyInThreadItemProps = { + childReport: OnyxEntry; + reportAction: ReportAction; + originalReport: OnyxEntry; + currentUserAccountID: number; + introSelected: OnyxEntry; + betas: OnyxEntry; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverReplyInThreadItem({ + childReport, + reportAction, + originalReport, + currentUserAccountID, + introSelected, + betas, + hideAndRun, + isFocused, + onFocus, + onBlur, +}: PopoverReplyInThreadItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['ChatBubbleReply'] as const); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowWidth} = useWindowDimensions(); + + return ( + + interceptAnonymousUser(() => { + hideAndRun(() => { + KeyboardUtils.dismiss().then(() => navigateToAndOpenChildReport(childReport, reportAction, originalReport, currentUserAccountID, introSelected, betas)); + }); + }, false) + } + wrapperStyle={[styles.pr8]} + style={StyleUtils.getContextMenuItemStyles(windowWidth)} + focused={isFocused} + interactive + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.REPLY_IN_THREAD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/replyInThreadAction.ts b/src/pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/replyInThreadAction.ts new file mode 100644 index 000000000000..0e0558de7912 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/ReplyInThreadAction/replyInThreadAction.ts @@ -0,0 +1,23 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {shouldDisableThread} from '@libs/ReportUtils'; +import type {ReportAction} from '@src/types/onyx'; + +function shouldShowReplyInThreadAction({ + reportAction, + reportID, + isThreadReportParentAction, + isArchivedRoom, +}: { + reportAction: OnyxEntry; + reportID: string | undefined; + isThreadReportParentAction: boolean; + isArchivedRoom: boolean; +}): boolean { + if (!reportID) { + return false; + } + return !shouldDisableThread(reportAction, isThreadReportParentAction, isArchivedRoom); +} + +// eslint-disable-next-line import/prefer-default-export -- named utility export per module convention +export {shouldShowReplyInThreadAction}; diff --git a/src/pages/inbox/report/ContextMenu/actions/UnholdAction/MiniUnholdItem.tsx b/src/pages/inbox/report/ContextMenu/actions/UnholdAction/MiniUnholdItem.tsx new file mode 100644 index 000000000000..ee5cc3f55770 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/UnholdAction/MiniUnholdItem.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import MiniContextMenuItem from '@components/MiniContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {changeMoneyRequestHoldStatus} from '@libs/ReportUtils'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {ReportAction, Transaction} from '@src/types/onyx'; + +type MiniUnholdItemProps = { + moneyRequestAction: ReportAction | undefined; + iouTransaction: OnyxEntry; + isOffline: boolean; + isDelegateAccessRestricted: boolean; + showDelegateNoAccessModal: (() => void) | undefined; + hideAndRun: (callback?: () => void) => void; +}; + +export default function MiniUnholdItem({moneyRequestAction, iouTransaction, isOffline, isDelegateAccessRestricted, showDelegateNoAccessModal, hideAndRun}: MiniUnholdItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Stopwatch'] as const); + + return ( + + interceptAnonymousUser(() => { + if (isDelegateAccessRestricted) { + hideContextMenu(false, showDelegateNoAccessModal); + return; + } + hideAndRun(() => changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction, isOffline)); + }, false) + } + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.UNHOLD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/UnholdAction/PopoverUnholdItem.tsx b/src/pages/inbox/report/ContextMenu/actions/UnholdAction/PopoverUnholdItem.tsx new file mode 100644 index 000000000000..7b44caf112bc --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/UnholdAction/PopoverUnholdItem.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import FocusableMenuItem from '@components/FocusableMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {changeMoneyRequestHoldStatus} from '@libs/ReportUtils'; +import {hideContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {ReportAction, Transaction} from '@src/types/onyx'; + +type PopoverUnholdItemProps = { + moneyRequestAction: ReportAction | undefined; + iouTransaction: OnyxEntry; + isOffline: boolean; + isDelegateAccessRestricted: boolean; + showDelegateNoAccessModal: (() => void) | undefined; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function PopoverUnholdItem({ + moneyRequestAction, + iouTransaction, + isOffline, + isDelegateAccessRestricted, + showDelegateNoAccessModal, + hideAndRun, + isFocused, + onFocus, + onBlur, +}: PopoverUnholdItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Stopwatch'] as const); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {windowWidth} = useWindowDimensions(); + + return ( + + interceptAnonymousUser(() => { + if (isDelegateAccessRestricted) { + hideContextMenu(false, showDelegateNoAccessModal); + return; + } + hideAndRun(() => changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction, isOffline)); + }, false) + } + wrapperStyle={[styles.pr8]} + style={StyleUtils.getContextMenuItemStyles(windowWidth)} + focused={isFocused} + interactive + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.UNHOLD} + /> + ); +} diff --git a/src/pages/inbox/report/ContextMenu/actions/UnholdAction/unholdAction.ts b/src/pages/inbox/report/ContextMenu/actions/UnholdAction/unholdAction.ts new file mode 100644 index 000000000000..f021a1c2c882 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/UnholdAction/unholdAction.ts @@ -0,0 +1,29 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {getReportAction} from '@libs/ReportActionsUtils'; +import {canHoldUnholdReportAction} from '@libs/ReportUtils'; +import type {Policy, ReportAction, Report as ReportType, Transaction} from '@src/types/onyx'; + +function shouldShowUnholdAction({ + moneyRequestReport, + moneyRequestAction, + moneyRequestPolicy, + areHoldRequirementsMet, + iouTransaction, + currentUserAccountID, +}: { + moneyRequestReport: OnyxEntry; + moneyRequestAction: ReportAction | undefined; + moneyRequestPolicy: OnyxEntry; + areHoldRequirementsMet: boolean; + iouTransaction: OnyxEntry; + currentUserAccountID: number; +}): boolean { + if (!areHoldRequirementsMet) { + return false; + } + const holdReportAction = getReportAction(moneyRequestAction?.childReportID, `${iouTransaction?.comment?.hold ?? ''}`); + return canHoldUnholdReportAction(moneyRequestReport, moneyRequestAction, holdReportAction, iouTransaction, moneyRequestPolicy, currentUserAccountID).canUnholdRequest; +} + +// eslint-disable-next-line import/prefer-default-export -- named utility export per module convention +export {shouldShowUnholdAction}; diff --git a/src/pages/inbox/report/ContextMenu/actions/UnpinAction.tsx b/src/pages/inbox/report/ContextMenu/actions/UnpinAction.tsx new file mode 100644 index 000000000000..356c497d3d34 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/UnpinAction.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import ContextMenuItem from '@components/ContextMenuItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; +import {togglePinnedState} from '@userActions/Report'; +import CONST from '@src/CONST'; + +type PopoverUnpinItemProps = { + reportID: string | undefined; + hideAndRun: (callback?: () => void) => void; + isFocused?: boolean; + onFocus?: () => void; + onBlur?: () => void; +}; + +function PopoverUnpinItem({reportID, hideAndRun, isFocused, onFocus, onBlur}: PopoverUnpinItemProps) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Pin'] as const); + + return ( + + interceptAnonymousUser(() => { + togglePinnedState(reportID, true); + hideAndRun(ReportActionComposeFocusManager.focus); + }) + } + isFocused={isFocused} + onFocus={onFocus} + onBlur={onBlur} + sentryLabel={CONST.SENTRY_LABEL.CONTEXT_MENU.UNPIN} + /> + ); +} + +function shouldShowUnpinAction({isPinnedChat}: {isPinnedChat: boolean}): boolean { + return isPinnedChat; +} + +export {shouldShowUnpinAction, PopoverUnpinItem}; diff --git a/src/pages/inbox/report/ContextMenu/actions/actionConfig.ts b/src/pages/inbox/report/ContextMenu/actions/actionConfig.ts new file mode 100644 index 000000000000..53fb76297b0e --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/actionConfig.ts @@ -0,0 +1,42 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type {ReportAction} from '@src/types/onyx'; + +const ACTION_IDS = { + EMOJI_REACTION: 'emojiReaction', + REPLY_IN_THREAD: 'replyInThread', + MARK_AS_UNREAD: 'markAsUnread', + EXPLAIN: 'explain', + MARK_AS_READ: 'markAsRead', + EDIT: 'edit', + UNHOLD: 'unhold', + HOLD: 'hold', + JOIN_THREAD: 'joinThread', + LEAVE_THREAD: 'leaveThread', + COPY_URL: 'copyUrl', + COPY_TO_CLIPBOARD: 'copyToClipboard', + COPY_EMAIL: 'copyEmail', + COPY_MESSAGE: 'copyMessage', + COPY_LINK: 'copyLink', + PIN: 'pin', + UNPIN: 'unpin', + FLAG_AS_OFFENSIVE: 'flagAsOffensive', + DOWNLOAD: 'download', + COPY_ONYX_DATA: 'copyOnyxData', + DEBUG: 'debug', + DELETE: 'delete', + OVERFLOW_MENU: 'overflowMenu', +} as const; + +type ActionID = ValueOf; + +function getActionHtml(reportAction: OnyxEntry): string { + const message = Array.isArray(reportAction?.message) ? (reportAction?.message?.at(-1) ?? null) : (reportAction?.message ?? null); + return message?.html ?? ''; +} + +/** Actions that are disabled when the user cannot write in the report. */ +const RESTRICTED_READONLY_ACTION_IDS = new Set([ACTION_IDS.REPLY_IN_THREAD, ACTION_IDS.EDIT, ACTION_IDS.JOIN_THREAD, ACTION_IDS.DELETE]); + +export {ACTION_IDS, RESTRICTED_READONLY_ACTION_IDS, getActionHtml}; +export type {ActionID}; diff --git a/src/pages/inbox/report/ContextMenu/actions/emojiReactionAction.ts b/src/pages/inbox/report/ContextMenu/actions/emojiReactionAction.ts new file mode 100644 index 000000000000..b2ed2f7b220b --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/actions/emojiReactionAction.ts @@ -0,0 +1,67 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {Emoji} from '@assets/emojis/types'; +import {isActionOfType, isMessageDeleted} from '@libs/ReportActionsUtils'; +import {toggleEmojiReaction} from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {ReportAction, ReportActionReactions} from '@src/types/onyx'; + +type EmojiReactionData = { + reportID: string | undefined; + reportAction: ReportAction | undefined; + reportActionID: string | undefined; + toggleEmojiAndCloseMenu: (emoji: Emoji, existingReactions: OnyxEntry, preferredSkinTone: number) => void; + closeContextMenu: (onHideCallback?: () => void) => void; + onPressOpenPicker: () => void; + onEmojiPickerClosed: () => void; +}; + +type EmojiReactionParams = { + reportID: string | undefined; + reportAction: ReportAction | undefined; + currentUserAccountID: number; + openContextMenu: () => void; + setIsEmojiPickerActive: ((state: boolean) => void) | undefined; + hideAndRun: (callback?: () => void) => void; +}; + +function shouldShowEmojiReaction({reportAction}: {reportAction: OnyxEntry}): boolean { + const isDEWRouted = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED); + return !!reportAction && 'message' in reportAction && !isMessageDeleted(reportAction) && !isDEWRouted; +} + +function createEmojiReactionData({reportID, reportAction, currentUserAccountID, openContextMenu, setIsEmojiPickerActive, hideAndRun}: EmojiReactionParams): EmojiReactionData { + const closeContextMenu = (onHideCallback?: () => void) => { + hideAndRun(onHideCallback); + }; + + const toggleEmojiAndCloseMenu = (emoji: Emoji, existingReactions: OnyxEntry, preferredSkinTone: number) => { + if (reportAction) { + toggleEmojiReaction(reportID, reportAction, emoji, existingReactions, preferredSkinTone, currentUserAccountID); + } + closeContextMenu(); + setIsEmojiPickerActive?.(false); + }; + + const onPressOpenPicker = () => { + openContextMenu(); + setIsEmojiPickerActive?.(true); + }; + + const onEmojiPickerClosed = () => { + closeContextMenu(); + setIsEmojiPickerActive?.(false); + }; + + return { + reportID, + reportAction, + reportActionID: reportAction?.reportActionID, + toggleEmojiAndCloseMenu, + closeContextMenu, + onPressOpenPicker, + onEmojiPickerClosed, + }; +} + +export default createEmojiReactionData; +export {shouldShowEmojiReaction}; diff --git a/src/pages/inbox/report/ContextMenu/useReportActionContextMenuData.ts b/src/pages/inbox/report/ContextMenu/useReportActionContextMenuData.ts new file mode 100644 index 000000000000..b84044cbfb33 --- /dev/null +++ b/src/pages/inbox/report/ContextMenu/useReportActionContextMenuData.ts @@ -0,0 +1,197 @@ +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import type {RefObject} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import {useSession} from '@components/OnyxListItemProvider'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useGetExpensifyCardFromReportAction from '@hooks/useGetExpensifyCardFromReportAction'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getMovedReportID} from '@libs/ModifiedExpenseMessage'; +import {getLinkedTransactionID, getOneTransactionThreadReportID, getOriginalMessage, getReportAction, isDeletedAction, withDEWRoutedActionsObject} from '@libs/ReportActionsUtils'; +import { + canWriteInReport, + chatIncludesChronosWithID, + getHarvestOriginalReportID, + getSourceIDFromReportAction, + isArchivedNonExpenseReport, + isChatThread, + isHarvestCreatedExpenseReport, + isInvoiceReport as ReportUtilsIsInvoiceReport, + isMoneyRequest as ReportUtilsIsMoneyRequest, + isMoneyRequestReport as ReportUtilsIsMoneyRequestReport, + isTrackExpenseReport as ReportUtilsIsTrackExpenseReport, +} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {OriginalMessageIOU, ReportAction} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {RESTRICTED_READONLY_ACTION_IDS} from './actions/actionConfig'; +import type {ContextMenuAnchor} from './ReportActionContextMenu'; + +const EMPTY_SET = new Set(); + +type UseContextMenuDataParams = { + reportID: string | undefined; + reportActionID: string | undefined; + originalReportID: string | undefined; + draftMessage: string; + selection: string; + anchor: RefObject | undefined; +}; + +/** + * Aggregates all Onyx data and derived state needed for context menus. + * Consumed by both PopoverReportActionContent (long-press menu) and MiniReportActionContextMenu (hover menu). + */ +function useReportActionContextMenuData({reportID, reportActionID, originalReportID, draftMessage, selection, anchor}: UseContextMenuDataParams) { + const {translate, getLocalDateFromDatetime} = useLocalize(); + const {isOffline} = useNetwork(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const encryptedAuthToken = useSession()?.encryptedAuthToken ?? ''; + const {isDelegateAccessRestricted} = useDelegateNoAccessState(); + const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + canEvict: false, + selector: withDEWRoutedActionsObject, + }); + const [originalReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, { + canEvict: false, + selector: withDEWRoutedActionsObject, + }); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`); + + const disabledActionIDs = !canWriteInReport(report) ? RESTRICTED_READONLY_ACTION_IDS : EMPTY_SET; + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(reportID)}`); + const [isDebugModeEnabled] = useOnyx(ONYXKEYS.IS_DEBUG_MODE_ENABLED); + const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [betas] = useOnyx(ONYXKEYS.BETAS); + + const hasValidReportAction = !isEmptyObject(originalReportActions) && reportActionID && reportActionID !== '0' && reportActionID !== '-1'; + const reportAction: OnyxEntry = hasValidReportAction ? originalReportActions[reportActionID] : undefined; + + const transactionID = getLinkedTransactionID(reportAction); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); + const [harvestReport] = useOnyx( + `${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(getHarvestOriginalReportID(reportNameValuePairs?.origin, reportNameValuePairs?.originalID))}`, + {}, + ); + const policyID = report?.policyID; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); + const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(reportAction, CONST.REPORT.MOVE_TYPE.FROM)}`); + const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(reportAction, CONST.REPORT.MOVE_TYPE.TO)}`); + const [download] = useOnyx(`${ONYXKEYS.COLLECTION.DOWNLOAD}${getSourceIDFromReportAction(reportAction)}`); + + const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction?.childReportID}`); + const [childReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportAction?.childReportID}`); + const [childChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${childReport?.chatReportID}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${childReport?.parentReportID}`); + const parentReportAction = getReportAction(childReport?.parentReportID, childReport?.parentReportActionID); + const {reportActions: paginatedReportActions} = usePaginatedReportActions(childReport?.reportID); + const transactionThreadReportID = getOneTransactionThreadReportID(childReport, childChatReport, paginatedReportActions ?? [], isOffline); + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionThreadReportID)}`); + + const isOriginalReportArchived = useReportIsArchived(originalReportID); + const isChildReportArchived = useReportIsArchived(childReport?.reportID); + const isParentReportArchived = useReportIsArchived(childReport?.parentReportID); + + const isChronosReport = chatIncludesChronosWithID(originalReportID); + const isArchivedRoom = isArchivedNonExpenseReport(originalReport, isOriginalReportArchived); + const isThreadReportParentAction = isChatThread(report) && report?.parentReportActionID === reportAction?.reportActionID; + + const isMoneyRequestReport = ReportUtilsIsMoneyRequestReport(childReport); + const isInvoiceReport = ReportUtilsIsInvoiceReport(childReport); + let requestParentReportAction; + if (isMoneyRequestReport || isInvoiceReport) { + if (transactionThreadReportID === CONST.FAKE_REPORT_ID) { + requestParentReportAction = Object.values(childReportActions ?? {}).find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !isDeletedAction(action)); + } else if (paginatedReportActions && transactionThreadReport?.parentReportActionID) { + requestParentReportAction = paginatedReportActions.find((action) => action.reportActionID === transactionThreadReport.parentReportActionID); + } + } else { + requestParentReportAction = parentReportAction; + } + const moneyRequestAction = transactionThreadReportID ? requestParentReportAction : parentReportAction; + + const iouTransactionID = (getOriginalMessage(moneyRequestAction ?? reportAction) as OriginalMessageIOU)?.IOUTransactionID; + const iouReportID = (getOriginalMessage(moneyRequestAction ?? reportAction) as OriginalMessageIOU)?.IOUReportID; + const [iouTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`); + const [moneyRequestPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport?.policyID}`); + const {transactions} = useTransactionsAndViolationsForReport(childReport?.reportID); + + const isMoneyRequest = ReportUtilsIsMoneyRequest(childReport); + const isTrackExpenseReport = ReportUtilsIsTrackExpenseReport(childReport); + const isSingleTransactionView = isMoneyRequest || isTrackExpenseReport; + const isMoneyRequestOrReport = isMoneyRequestReport || isSingleTransactionView; + const archivedReportForHold = transactionThreadReportID ? childReport : parentReport; + const isArchivedForHold = transactionThreadReportID ? isChildReportArchived : isParentReportArchived; + const areHoldRequirementsMet = !isInvoiceReport && isMoneyRequestOrReport && !isArchivedNonExpenseReport(archivedReportForHold, isArchivedForHold); + + const isHarvestReport = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); + const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; + + const card = useGetExpensifyCardFromReportAction({reportAction, policyID}); + + return { + report, + originalReport, + reportActions, + reportAction, + childReport, + childReportActions, + policy, + policyTags, + moneyRequestAction, + moneyRequestReport, + moneyRequestPolicy, + iouTransaction, + transaction, + card, + currentUserPersonalDetails, + encryptedAuthToken, + isArchivedRoom, + isChronosReport, + isThreadReportParentAction, + isOffline: !!isOffline, + isHarvestReport, + isTryNewDotNVPDismissed, + isDelegateAccessRestricted: !!isDelegateAccessRestricted, + areHoldRequirementsMet, + isDebugModeEnabled, + transactions, + introSelected, + isSelfTourViewed, + betas, + bankAccountList, + conciergeReportID, + movedFromReport, + movedToReport, + harvestReport, + download, + disabledActionIDs, + showDelegateNoAccessModal, + translate, + getLocalDateFromDatetime, + reportID, + originalReportID, + draftMessage, + selection, + anchor, + }; +} + +export default useReportActionContextMenuData; +export type {UseContextMenuDataParams}; diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 5cd0f49fe7df..a68b09a581c7 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -126,7 +126,6 @@ import { } from '@libs/ReportActionsUtils'; import type {CreateDraftTransactionParams, MissingPaymentMethod} from '@libs/ReportUtils'; import { - canWriteInReport, chatIncludesConcierge, getChatListItemReportName, getDisplayNamesWithTooltips, @@ -167,8 +166,7 @@ import PaymentContent from './actionContents/PaymentContent'; import PolicyChangeLogContent, {isHandledPolicyChangeLogAction} from './actionContents/PolicyChangeLogContent'; import ReportMentionWhisperContent from './actionContents/ReportMentionWhisperContent'; import SimpleMessageContent, {isSimpleMessageAction} from './actionContents/SimpleMessageContent'; -import {RestrictedReadOnlyContextMenuActions} from './ContextMenu/ContextMenuActions'; -import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMenu'; +import {useMiniContextMenuActions} from './ContextMenu/MiniContextMenuProvider'; import type {ContextMenuAnchor} from './ContextMenu/ReportActionContextMenu'; import {hideContextMenu, hideDeleteModal, isActiveReportAction, showContextMenu} from './ContextMenu/ReportActionContextMenu'; import LinkPreviewer from './LinkPreviewer'; @@ -292,9 +290,6 @@ type PureReportActionItemProps = { /** Whether the room is archived */ isArchivedRoom?: boolean; - /** Whether the room is a chronos report */ - isChronosReport?: boolean; - /** All cards */ cardList?: OnyxTypes.CardList; @@ -438,7 +433,6 @@ function PureReportActionItem({ originalReport, deleteReportActionDraft = () => {}, isArchivedRoom, - isChronosReport, toggleEmojiReaction = () => {}, createDraftTransactionAndNavigateToParticipantSelector = () => {}, resolveActionableReportMentionWhisper = () => {}, @@ -473,6 +467,7 @@ function PureReportActionItem({ const {transitionActionSheetState} = ActionSheetAwareScrollView.useActionSheetAwareScrollViewActions(); const {translate, formatPhoneNumber, localeCompare, formatTravelDate, datetimeToCalendarTime} = useLocalize(); const {showConfirmModal} = useConfirmModal(); + const {showMiniContextMenu, hideMiniContextMenu, hideMiniContextMenuWithoutNotification, menuContainerRef} = useMiniContextMenuActions(); const personalDetail = useCurrentUserPersonalDetails(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const reportID = report?.reportID ?? action?.reportID; @@ -492,6 +487,7 @@ function PureReportActionItem({ const kycWallRef = useContext(KYCWallContext); const composerTextInputRef = useRef(null); const popoverAnchorRef = useRef>(null); + const isPointerOverReportActionRowRef = useRef(false); const downloadedPreviews = useRef([]); const prevDraftMessage = usePrevious(draftMessage); const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; @@ -662,6 +658,31 @@ function PureReportActionItem({ setIsHidden(false); }, [latestDecision, action]); + useEffect(() => { + if (!isContextMenuActive) { + return; + } + const el = popoverAnchorRef.current as unknown as HTMLElement | null; + if (!el) { + return; + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab' || e.shiftKey) { + return; + } + const firstButton = menuContainerRef.current?.querySelector('[role="button"]') as HTMLElement | null; + if (!firstButton) { + return; + } + e.preventDefault(); + firstButton.focus(); + }; + + el.addEventListener('keydown', onKeyDown); + return () => el.removeEventListener('keydown', onKeyDown); + }, [isContextMenuActive, menuContainerRef]); + const toggleContextMenuFromActiveReportAction = useCallback(() => { setIsContextMenuActive(isActiveReportAction(action.reportActionID)); }, [action.reportActionID]); @@ -689,8 +710,6 @@ function PureReportActionItem({ [transitionActionSheetState], ); - const disabledActions = useMemo(() => (!canWriteInReport(report) ? RestrictedReadOnlyContextMenuActions : []), [report]); - /** * Show the ReportActionContextMenu modal popover. * @@ -704,6 +723,7 @@ function PureReportActionItem({ } handleShowContextMenu(() => { + hideMiniContextMenuWithoutNotification(); setIsContextMenuActive(true); const selection = SelectionScraper.getCurrentSelection(); showContextMenu({ @@ -714,20 +734,42 @@ function PureReportActionItem({ report: { reportID, originalReportID, - isArchivedRoom, - isChronos: isChronosReport, }, reportAction: { reportActionID: action.reportActionID, draftMessage, - isThreadReportParentAction, }, callbacks: { onShow: toggleContextMenuFromActiveReportAction, - onHide: toggleContextMenuFromActiveReportAction, + onHide: () => { + setIsContextMenuActive(false); + if (isPointerOverReportActionRowRef.current && shouldDisplayContextMenuValue && draftMessage === undefined && isEmptyValueObject(action.errors)) { + const node = popoverAnchorRef.current; + if (!node || !('getBoundingClientRect' in node)) { + return; + } + const rect = node.getBoundingClientRect(); + showMiniContextMenu({ + reportID, + reportActionID: action.reportActionID, + originalReportID, + anchor: popoverAnchorRef, + displayAsGroup: !!displayAsGroup, + draftMessage, + checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, + setIsEmojiPickerActive, + rowMeasurements: { + top: rect.top, + height: rect.height, + right: rect.right, + }, + onMenuHide: () => setIsContextMenuActive(false), + }); + setIsContextMenuActive(true); + } + }, setIsEmojiPickerActive: setIsEmojiPickerActive as () => void, }, - disabledOptions: disabledActions, }); }); }, @@ -739,11 +781,10 @@ function PureReportActionItem({ toggleContextMenuFromActiveReportAction, originalReportID, shouldDisplayContextMenuValue, - disabledActions, - isArchivedRoom, - isChronosReport, handleShowContextMenu, - isThreadReportParentAction, + hideMiniContextMenuWithoutNotification, + showMiniContextMenu, + displayAsGroup, ], ); @@ -1651,31 +1692,42 @@ function PureReportActionItem({ shouldFreezeCapture={isPaymentMethodPopoverActive} onHoverIn={() => { setIsReportActionActive(false); + if (!shouldDisplayContextMenuValue || draftMessage !== undefined || hasErrors) { + return; + } + isPointerOverReportActionRowRef.current = true; + const node = popoverAnchorRef.current; + if (!node || !('getBoundingClientRect' in node)) { + return; + } + const rect = node.getBoundingClientRect(); + showMiniContextMenu({ + reportID, + reportActionID: action.reportActionID, + originalReportID, + anchor: popoverAnchorRef, + displayAsGroup: !!displayAsGroup, + draftMessage, + checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, + setIsEmojiPickerActive, + rowMeasurements: { + top: rect.top, + height: rect.height, + right: rect.right, + }, + onMenuHide: () => setIsContextMenuActive(false), + }); + setIsContextMenuActive(true); }} onHoverOut={() => { + isPointerOverReportActionRowRef.current = false; setIsReportActionActive(!!isReportActionLinked); + hideMiniContextMenu(); }} > {(hovered) => ( {shouldDisplayNewMarker && (!shouldUseThreadDividerLine || !isFirstVisibleReportAction) && } - {shouldDisplayContextMenuValue && ( - - )} { prevProps.originalReportID === nextProps.originalReportID && deepEqual(prevProps.originalReport?.participants, nextProps.originalReport?.participants) && prevProps.isArchivedRoom === nextProps.isArchivedRoom && - prevProps.isChronosReport === nextProps.isChronosReport && prevProps.isClosedExpenseReportWithNoExpenses === nextProps.isClosedExpenseReportWithNoExpenses && deepEqual(prevProps.missingPaymentMethod, nextProps.missingPaymentMethod) && prevProps.reimbursementDeQueuedOrCanceledActionMessage === nextProps.reimbursementDeQueuedOrCanceledActionMessage && diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 114e4491d4e7..6105c531ae11 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -13,7 +13,6 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getForReportAction, getMovedReportID} from '@libs/ModifiedExpenseMessage'; import {getIOUReportIDFromReportActionPreview, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import { - chatIncludesChronosWithID, createDraftTransactionAndNavigateToParticipantSelector, getIndicatedMissingPaymentMethod, getReimbursementDeQueuedOrCanceledActionMessage, @@ -168,7 +167,6 @@ function ReportActionItem({ originalReport={originalReport} deleteReportActionDraft={deleteReportActionDraft} isArchivedRoom={isArchivedNonExpenseReport(originalReport, isOriginalReportArchived)} - isChronosReport={chatIncludesChronosWithID(originalReportID)} toggleEmojiReaction={toggleEmojiReaction} createDraftTransactionAndNavigateToParticipantSelector={createDraftTransactionAndNavigateToParticipantSelector} resolveActionableReportMentionWhisper={resolveActionableReportMentionWhisper} diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index edc6ec42d00a..35666013c235 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1756,14 +1756,15 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ /** * Generate the wrapper styles for the mini ReportActionContextMenu. */ - getMiniReportActionContextMenuWrapperStyle: (isReportActionItemGrouped: boolean): ViewStyle => ({ - ...(isReportActionItemGrouped ? positioning.tn8 : positioning.tn4), - ...positioning.r4, + getMiniReportActionContextMenuWrapperStyle: (pos: {top: number; right: number} | null, isVisible: boolean): ViewStyle => ({ + position: 'absolute', + zIndex: 8, + top: pos?.top ?? 0, + right: pos?.right ?? 0, + opacity: isVisible && pos ? 1 : 0, ...styles.cursorDefault, ...styles.userSelectNone, overflowAnchor: 'none', - position: 'absolute', - zIndex: 8, }), /** diff --git a/tests/ui/ContextMenuOrderingTest.tsx b/tests/ui/ContextMenuOrderingTest.tsx new file mode 100644 index 000000000000..013cebc5a4cc --- /dev/null +++ b/tests/ui/ContextMenuOrderingTest.tsx @@ -0,0 +1,257 @@ +import {PortalProvider} from '@gorhom/portal'; +import * as NativeNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import type {RenderResult} from '@testing-library/react-native'; +import React, {useRef} from 'react'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import DelegateNoAccessModalProvider from '@components/DelegateNoAccessModalProvider'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import OptionsListContextProvider from '@components/OptionListContextProvider'; +import ScreenWrapper from '@components/ScreenWrapper'; +import PopoverReportActionContent from '@pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverReportActionContent'; +import PopoverReportContent from '@pages/inbox/report/ContextMenu/PopoverContextMenu/PopoverReportContent'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction} from '@src/types/onyx'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; + +jest.mock('@react-navigation/native'); + +const CURRENT_USER_ACCOUNT_ID = 11111111; +const CURRENT_USER_EMAIL = 'me@test.com'; +const OTHER_USER_ACCOUNT_ID = 22222222; +const OTHER_USER_EMAIL = 'other@test.com'; +const REPORT_ID = 'testReport'; + +/** + * Collect rendered context-menu items in tree order, de-duplicated by + * `sentryLabel` (since the prop propagates through several nested wrappers + * — MenuItem → PressableWithSecondaryInteraction → … — all of which expose + * the same `sentryLabel`). + */ +function collectSentryLabels(root: RenderResult['root']): string[] { + const matches = root.findAll((el) => { + const label: unknown = el.props?.sentryLabel; + return typeof label === 'string' && label.startsWith('ContextMenu-'); + }); + const seen = new Set(); + const ordered: string[] = []; + for (const match of matches) { + const label = match.props.sentryLabel as string; + if (seen.has(label)) { + continue; + } + seen.add(label); + ordered.push(label); + } + return ordered; +} + +function buildTextCommentAction(overrides: Partial = {}): ReportAction { + return { + reportActionID: '100', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: OTHER_USER_ACCOUNT_ID, + created: '2026-04-15 09:00:00.000', + automatic: false, + shouldShow: true, + avatar: '', + person: [{type: 'TEXT', style: 'strong', text: OTHER_USER_EMAIL}], + message: [{type: 'COMMENT', html: '

hello world

', text: 'hello world'}], + originalMessage: {html: '

hello world

'}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(overrides as any), + } as ReportAction; +} + +function PopoverReportActionContentHarness({reportActionID = '100'}: {reportActionID?: string | undefined}) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contentRef = useRef(null); + return ( + {}} + setLocalShouldKeepOpen={() => {}} + contentRef={contentRef} + shouldEnableArrowNavigation={false} + /> + ); +} + +function PopoverReportContentHarness() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contentRef = useRef(null); + return ( + {}} + contentRef={contentRef} + shouldEnableArrowNavigation={false} + /> + ); +} + +function renderWithProviders(element: React.ReactElement) { + return render( + + + + + {element} + + + + , + ); +} + +describe('Context menu item ordering', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + jest.spyOn(NativeNavigation, 'useRoute').mockReturnValue({key: '', name: ''}); + }); + + beforeEach(async () => { + wrapOnyxWithWaitForBatchedUpdates(Onyx); + await act(async () => { + await Onyx.merge(ONYXKEYS.SESSION, { + accountID: CURRENT_USER_ACCOUNT_ID, + email: CURRENT_USER_EMAIL, + encryptedAuthToken: 'fake-token', + }); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [CURRENT_USER_ACCOUNT_ID]: { + accountID: CURRENT_USER_ACCOUNT_ID, + login: CURRENT_USER_EMAIL, + displayName: CURRENT_USER_EMAIL, + }, + [OTHER_USER_ACCOUNT_ID]: { + accountID: OTHER_USER_ACCOUNT_ID, + login: OTHER_USER_EMAIL, + displayName: OTHER_USER_EMAIL, + }, + }); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + await waitForBatchedUpdatesWithAct(); + }); + + async function seedReport(report: Partial, reportActions: Record) { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, { + reportID: REPORT_ID, + chatType: undefined, + ...report, + } as Report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, reportActions); + }); + await waitForBatchedUpdatesWithAct(); + } + + describe('PopoverReportActionContent (right-click on a message)', () => { + it('renders items in the canonical order for a plain comment by another user', async () => { + const action = buildTextCommentAction({actorAccountID: OTHER_USER_ACCOUNT_ID}); + await seedReport({}, {[action.reportActionID]: action}); + + const {root} = renderWithProviders(); + await waitForBatchedUpdatesWithAct(); + + // Another user's action defaults to HIDDEN notification preference, so + // Join thread is offered between Mark as unread and Copy message. + expect(collectSentryLabels(root)).toEqual([ + CONST.SENTRY_LABEL.CONTEXT_MENU.REPLY_IN_THREAD, + CONST.SENTRY_LABEL.CONTEXT_MENU.MARK_AS_UNREAD, + CONST.SENTRY_LABEL.CONTEXT_MENU.JOIN_THREAD, + CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_MESSAGE, + CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_LINK, + CONST.SENTRY_LABEL.CONTEXT_MENU.FLAG_AS_OFFENSIVE, + ]); + }); + + it('renders items in the canonical order for the current user’s own comment', async () => { + const action = buildTextCommentAction({actorAccountID: CURRENT_USER_ACCOUNT_ID, reportActionID: '101'}); + await seedReport({}, {[action.reportActionID]: action}); + + const {root} = renderWithProviders(); + await waitForBatchedUpdatesWithAct(); + + // The current user is treated as the creator of their own action, so + // Leave thread surfaces between Edit and Copy message. + expect(collectSentryLabels(root)).toEqual([ + CONST.SENTRY_LABEL.CONTEXT_MENU.REPLY_IN_THREAD, + CONST.SENTRY_LABEL.CONTEXT_MENU.MARK_AS_UNREAD, + CONST.SENTRY_LABEL.CONTEXT_MENU.EDIT_COMMENT, + CONST.SENTRY_LABEL.CONTEXT_MENU.LEAVE_THREAD, + CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_MESSAGE, + CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_LINK, + CONST.SENTRY_LABEL.CONTEXT_MENU.DELETE, + ]); + }); + + it('renders only the overflow "Menu" item when no reportAction is supplied', async () => { + await seedReport({}, {}); + + const {root} = renderWithProviders(); + await waitForBatchedUpdatesWithAct(); + + expect(collectSentryLabels(root)).toEqual([CONST.SENTRY_LABEL.CONTEXT_MENU.MENU]); + }); + }); + + describe('PopoverReportContent (right-click on a report row)', () => { + it('renders items in the canonical order for a read, unpinned chat', async () => { + await seedReport( + { + isPinned: false, + lastMessageText: 'hello', + lastReadTime: '2100-01-01 00:00:00.000', + lastVisibleActionCreated: '2020-01-01 00:00:00.000', + }, + {}, + ); + + const {root} = renderWithProviders(); + await waitForBatchedUpdatesWithAct(); + + expect(collectSentryLabels(root)).toEqual([CONST.SENTRY_LABEL.CONTEXT_MENU.MARK_AS_UNREAD, CONST.SENTRY_LABEL.CONTEXT_MENU.PIN]); + }); + + it('renders Mark as read and Unpin for an unread, pinned chat', async () => { + await seedReport( + { + isPinned: true, + lastMessageText: 'hello', + lastReadTime: '2020-01-01 00:00:00.000', + lastVisibleActionCreated: '2100-01-01 00:00:00.000', + }, + {}, + ); + + const {root} = renderWithProviders(); + await waitForBatchedUpdatesWithAct(); + + expect(collectSentryLabels(root)).toEqual([CONST.SENTRY_LABEL.CONTEXT_MENU.MARK_AS_READ, CONST.SENTRY_LABEL.CONTEXT_MENU.UNPIN]); + }); + }); +}); diff --git a/tests/ui/components/BaseReportActionContextMenuTest.tsx b/tests/ui/components/BaseReportActionContextMenuTest.tsx deleted file mode 100644 index 8c685c76d309..000000000000 --- a/tests/ui/components/BaseReportActionContextMenuTest.tsx +++ /dev/null @@ -1,397 +0,0 @@ -import {act, render, waitFor} from '@testing-library/react-native'; -import React from 'react'; -import Onyx from 'react-native-onyx'; -import BaseReportActionContextMenu from '@pages/inbox/report/ContextMenu/BaseReportActionContextMenu'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {PersonalDetailsList} from '@src/types/onyx'; -import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; - -jest.mock('@components/ActionSheetAwareScrollView', () => ({ - useActionSheetAwareScrollViewActions: () => ({ - transitionActionSheetState: jest.fn(), - }), -})); - -type MockContextMenuItemProps = { - sentryLabel?: string; - onPress?: (event: unknown) => void; - [key: string]: unknown; -}; - -const mockContextMenuItemProps: MockContextMenuItemProps[] = []; - -jest.mock('@components/ContextMenuItem', () => { - function MockContextMenuItem(props: MockContextMenuItemProps) { - const {sentryLabel, onPress} = props; - mockContextMenuItemProps.push({...props, sentryLabel, onPress}); - return null; - } - - return MockContextMenuItem; -}); - -jest.mock('@components/DelegateNoAccessModalProvider', () => ({ - useDelegateNoAccessState: () => ({isDelegateAccessRestricted: false}), - useDelegateNoAccessActions: () => ({showDelegateNoAccessModal: jest.fn()}), -})); - -jest.mock('@components/FocusTrap/FocusTrapForModal', () => { - function MockFocusTrapForModal({children}: {children: React.ReactNode}) { - return children; - } - - return MockFocusTrapForModal; -}); - -jest.mock('@components/OnyxListItemProvider', () => ({ - useSession: () => ({encryptedAuthToken: 'token'}), -})); - -jest.mock('@hooks/useArrowKeyFocusManager', () => () => [-1, jest.fn()] as const); -jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1, login: 'user@test.com', email: 'user@test.com'})); -jest.mock('@hooks/useEnvironment', () => () => ({isProduction: false})); -jest.mock('@hooks/useGetExpensifyCardFromReportAction', () => () => undefined); -jest.mock('@hooks/useLazyAsset', () => ({ - useMemoizedLazyExpensifyIcons: () => ({ - Bell: undefined, - Bug: undefined, - ChatBubbleReply: undefined, - ChatBubbleUnread: undefined, - Checkmark: undefined, - Concierge: undefined, - Copy: undefined, - Download: undefined, - Exit: undefined, - Flag: undefined, - LinkCopy: undefined, - Mail: undefined, - Pencil: undefined, - Pin: undefined, - Stopwatch: undefined, - ThreeDots: undefined, - Trashcan: undefined, - }), -})); - -jest.mock('@hooks/useLocalize', () => () => ({ - translate: (key: string) => key, - getLocalDateFromDatetime: jest.fn(), -})); -jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); -jest.mock('@hooks/usePaginatedReportActions', () => () => ({reportActions: []})); -jest.mock('@hooks/useReportIsArchived', () => () => false); -jest.mock('@hooks/useResponsiveLayout', () => () => ({shouldUseNarrowLayout: true, isSmallScreenWidth: false})); -jest.mock('@hooks/useRestoreInputFocus', () => () => {}); -jest.mock( - '@hooks/useStyleUtils', - () => () => - new Proxy( - {}, - { - get: () => () => ({}), - }, - ), -); -jest.mock('@hooks/useTransactionsAndViolationsForReport', () => () => ({transactions: {}})); - -jest.mock('@userActions/Session', () => ({ - isAnonymousUser: () => false, - signOutAndRedirectToSignIn: jest.fn(), - callFunctionIfActionIsAllowed: (fn: () => void) => fn, -})); - -jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => ({ - hideContextMenu: jest.fn((_: boolean, onHideCallback?: () => void) => onHideCallback?.()), - showContextMenu: jest.fn(), -})); - -const mockUnholdRequest = jest.fn(); -jest.mock('@libs/actions/IOU/Hold', () => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- Ignoring type errors for testing purposes - const actual = jest.requireActual('@libs/actions/IOU/Hold'); - return { - ...actual, - // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- Ignoring type errors for testing purposes - unholdRequest: (...args: Parameters) => mockUnholdRequest(...args), - }; -}); - -const mockNavigate = jest.fn(); -const mockSetParams = jest.fn(); -const mockIsReady = jest.fn(() => false); -const mockGetActiveRoute = jest.fn(() => ''); -const mockGetCurrentRoute = jest.fn(() => undefined as {name: string; params: Record} | undefined); - -jest.mock('@libs/Navigation/Navigation', () => ({ - navigate: (...args: unknown[]) => mockNavigate(...args) as void, - setParams: (...args: unknown[]) => mockSetParams(...args) as void, - getActiveRoute: () => mockGetActiveRoute(), - navigationRef: { - isReady: () => mockIsReady(), - getCurrentRoute: () => mockGetCurrentRoute(), - }, -})); - -const currentUserAccountID = 1; -const originalReportID = '100'; -const reportActionID = '200'; -const childReportID = '300'; -const iouReportID = '400'; -const transactionID = '500'; -const policyID = 'policy1'; -const holdActionID = 'holdAction1'; -const testPersonalDetails: PersonalDetailsList = { - [currentUserAccountID]: { - accountID: currentUserAccountID, - login: 'user@test.com', - displayName: 'Test User', - }, -}; -async function seedOnyxData({isOnHold}: {isOnHold: boolean}) { - await Onyx.clear(); - - // Session and personal details for current user (needed by canHoldUnholdReportAction / canModifyHoldStatus) - await Onyx.merge(ONYXKEYS.SESSION, { - accountID: currentUserAccountID, - email: 'user@test.com', - }); - - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, testPersonalDetails); - - // Policy (needed by canModifyHoldStatus for isPolicyAdmin, and by changeMoneyRequestHoldStatus) - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { - id: policyID, - type: CONST.POLICY.TYPE.TEAM, - role: CONST.POLICY.ROLE.ADMIN, - }); - - // Original report (chat report containing the report action) - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`, { - reportID: originalReportID, - type: CONST.REPORT.TYPE.CHAT, - }); - - // Report actions on the original report - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, { - [reportActionID]: { - reportActionID, - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - childReportID, - }, - parentIOUAction: { - reportActionID: 'parentIOUAction', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - actorAccountID: currentUserAccountID, - childReportID, - originalMessage: { - IOUReportID: iouReportID, - IOUTransactionID: transactionID, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - }, - }, - }); - - // Child report (expense report) linked from the report action - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${childReportID}`, { - reportID: childReportID, - type: CONST.REPORT.TYPE.EXPENSE, - parentReportID: originalReportID, - parentReportActionID: 'parentIOUAction', - policyID, - managerID: currentUserAccountID, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - }); - - // Report actions on child report - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${childReportID}`, { - iouAction: { - reportActionID: 'iouAction', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - actorAccountID: currentUserAccountID, - childReportID, - originalMessage: { - IOUReportID: iouReportID, - IOUTransactionID: transactionID, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - }, - }, - ...(isOnHold - ? { - [holdActionID]: { - reportActionID: holdActionID, - actionName: CONST.REPORT.ACTIONS.TYPE.HOLD, - actorAccountID: currentUserAccountID, - }, - } - : {}), - }); - - // IOU report referenced by the money request action - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { - reportID: iouReportID, - type: CONST.REPORT.TYPE.EXPENSE, - policyID, - managerID: currentUserAccountID, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - }); - - // Transaction (on hold or not) - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { - transactionID, - reportID: iouReportID, - comment: isOnHold ? {hold: holdActionID} : {}, - }); - - await waitForBatchedUpdates(); -} - -async function getContextMenuItemOnPress(sentryLabel: string): Promise<(event: unknown) => void> { - let contextMenuItem = mockContextMenuItemProps.find((item) => item.sentryLabel === sentryLabel); - - await waitFor(() => { - contextMenuItem = mockContextMenuItemProps.find((item) => item.sentryLabel === sentryLabel); - expect(contextMenuItem).toBeDefined(); - expect(contextMenuItem?.onPress).toBeDefined(); - }); - - return contextMenuItem?.onPress ?? (() => undefined); -} - -describe('BaseReportActionContextMenu edit action', () => { - beforeEach(async () => { - jest.clearAllMocks(); - mockContextMenuItemProps.length = 0; - }); - - beforeAll(() => { - Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); - }); - - it('shows the edit action for an editable comment by current user', async () => { - await seedOnyxData({isOnHold: false}); - - // Override the report action to be a plain ADD_COMMENT (editable) - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, { - [reportActionID]: { - reportActionID, - actorAccountID: currentUserAccountID, - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - message: [ - { - type: 'COMMENT', - html: 'Hello world', - text: 'Hello world', - }, - ], - created: '2025-03-05 16:34:27', - }, - }); - await waitForBatchedUpdates(); - - render( - , - ); - - await waitFor(() => { - const editItem = mockContextMenuItemProps.find((item) => item.sentryLabel === CONST.SENTRY_LABEL.CONTEXT_MENU.EDIT_COMMENT); - expect(editItem).toBeDefined(); - }); - }); - - it('does not show the edit action for a comment by another user', async () => { - await seedOnyxData({isOnHold: false}); - - const otherUserAccountID = 999; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, { - [reportActionID]: { - reportActionID, - actorAccountID: otherUserAccountID, - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - message: [ - { - type: 'COMMENT', - html: 'Hello from another user', - text: 'Hello from another user', - }, - ], - created: '2025-03-05 16:34:27', - }, - }); - await waitForBatchedUpdates(); - - render( - , - ); - - await waitForBatchedUpdates(); - const editItem = mockContextMenuItemProps.find((item) => item.sentryLabel === CONST.SENTRY_LABEL.CONTEXT_MENU.EDIT_COMMENT); - expect(editItem).toBeUndefined(); - }); -}); - -describe('BaseReportActionContextMenu hold/unhold action', () => { - beforeEach(async () => { - jest.clearAllMocks(); - mockContextMenuItemProps.length = 0; - }); - - beforeAll(() => { - Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); - }); - - it('navigates to hold reason page when pressing the hold action', async () => { - await seedOnyxData({isOnHold: false}); - - render( - , - ); - - const onPress = await getContextMenuItemOnPress(CONST.SENTRY_LABEL.CONTEXT_MENU.HOLD); - await act(async () => { - onPress({}); - }); - - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(CONST.POLICY.TYPE.TEAM, transactionID, childReportID, encodeURIComponent(mockGetActiveRoute()))); - }); - - it('calls unholdRequest when pressing the unhold action', async () => { - await seedOnyxData({isOnHold: true}); - - render( - , - ); - - const onPress = await getContextMenuItemOnPress(CONST.SENTRY_LABEL.CONTEXT_MENU.UNHOLD); - await act(async () => { - onPress({}); - }); - - expect(mockUnholdRequest).toHaveBeenCalledTimes(1); - expect(mockUnholdRequest).toHaveBeenCalledWith(transactionID, childReportID, expect.objectContaining({id: policyID}), false); - }); -}); diff --git a/tests/unit/ContextMenuActionsCopyMessageTest.ts b/tests/unit/ContextMenuActionsCopyMessageTest.ts index 70ef7ee7b5c5..546151f4edd2 100644 --- a/tests/unit/ContextMenuActionsCopyMessageTest.ts +++ b/tests/unit/ContextMenuActionsCopyMessageTest.ts @@ -1,6 +1,9 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import Clipboard from '@libs/Clipboard'; import getClipboardText from '@libs/Clipboard/getClipboardText'; +import {copyMessageToClipboard} from '@pages/inbox/report/ContextMenu/actions/CopyMessageAction/copyMessageAction'; import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; jest.mock( 'expo-web-browser', @@ -36,27 +39,28 @@ const mockClipboard = Clipboard as { }; const mockGetClipboardText = getClipboardText as jest.Mock; -type ContextMenuAction = { - sentryLabel?: string; - onPress?: (closePopover: boolean, payload: Record) => void; -}; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const {default: ContextMenuActions} = require('@pages/inbox/report/ContextMenu/ContextMenuActions') as {default: ContextMenuAction[]}; - -const copyMessageAction = ContextMenuActions.find((action) => action.sentryLabel === CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_MESSAGE); - -const createPayload = (selection: string): Record => ({ +const createParams = (selection: string) => ({ reportAction: { actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, message: [{html: selection}], - }, + } as ReportAction, + transaction: undefined, selection, - report: {}, - originalReport: {}, + report: undefined, + conciergeReportID: undefined, + bankAccountList: undefined, + card: undefined, + originalReport: undefined, + isHarvestReport: false, + isTryNewDotNVPDismissed: false, + movedFromReport: undefined, + movedToReport: undefined, + childReport: undefined, + policy: undefined, getLocalDateFromDatetime: jest.fn(), - policyTags: {}, - translate: (translateKey: string) => translateKey, + policyTags: undefined, + translate: ((translateKey: string) => translateKey) as LocalizedTranslate, + harvestReport: undefined, currentUserPersonalDetails: { accountID: 1, login: 'user@expensify.com', @@ -74,11 +78,7 @@ describe('ContextMenuActions copy message', () => { mockClipboard.canSetHtml.mockReturnValue(false); mockGetClipboardText.mockReturnValue('Expensify'); - if (!copyMessageAction?.onPress) { - throw new Error('Copy message context menu action was not found'); - } - - copyMessageAction.onPress(false, createPayload(selection)); + copyMessageToClipboard(createParams(selection)); expect(mockGetClipboardText).toHaveBeenCalledWith(selection); expect(mockClipboard.setString).toHaveBeenCalledWith('Expensify'); @@ -90,11 +90,7 @@ describe('ContextMenuActions copy message', () => { mockClipboard.canSetHtml.mockReturnValue(true); mockGetClipboardText.mockReturnValue('Expensify'); - if (!copyMessageAction?.onPress) { - throw new Error('Copy message context menu action was not found'); - } - - copyMessageAction.onPress(false, createPayload(selection)); + copyMessageToClipboard(createParams(selection)); expect(mockGetClipboardText).toHaveBeenCalledWith(selection); expect(mockClipboard.setHtml).toHaveBeenCalledWith(selection, 'Expensify');