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');