diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 188ab5646d30..c4c93451498b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,4 +1,4 @@ -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; import type * as FormTypes from './types/form'; @@ -589,7 +589,7 @@ type OnyxFormDraftKey = keyof OnyxFormDraftValuesMapping; type OnyxValueKey = keyof OnyxValuesMapping; type OnyxKey = OnyxValueKey | OnyxCollectionKey | OnyxFormKey | OnyxFormDraftKey; -type OnyxValue = OnyxEntry; +type OnyxValue = TOnyxKey extends keyof OnyxCollectionValuesMapping ? OnyxCollection : OnyxEntry; type MissingOnyxKeysError = `Error: Types don't match, OnyxKey type is missing: ${Exclude}`; /** If this type errors, it means that the `OnyxKey` type is missing some keys. */ diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index a25c7ff7129c..ca1bc391e800 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -198,7 +198,7 @@ function Button( accessibilityLabel = '', ...rest }: ButtonProps, - ref: ForwardedRef, + ref: ForwardedRef, ) { const theme = useTheme(); const styles = useThemeStyles(); diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index 9b68916c4003..88938f31cd79 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -1,6 +1,7 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useEffect, useRef} from 'react'; -import type {GestureResponderEvent, Role} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, Role, Text} from 'react-native'; import {Platform, View} from 'react-native'; import Animated, {createAnimatedPropAdapter, Easing, interpolateColor, processColor, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import Svg, {Path} from 'react-native-svg'; @@ -58,12 +59,12 @@ type FloatingActionButtonProps = { role: Role; }; -function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: FloatingActionButtonProps, ref: ForwardedRef) { +function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: FloatingActionButtonProps, ref: ForwardedRef) { const {success, buttonDefaultBG, textLight, textDark} = useTheme(); const styles = useThemeStyles(); const borderRadius = styles.floatingActionButton.borderRadius; const {translate} = useLocalize(); - const fabPressable = useRef(null); + const fabPressable = useRef(null); const sharedValue = useSharedValue(isActive ? 1 : 0); const buttonRef = ref; @@ -112,9 +113,9 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo { - fabPressable.current = el; + fabPressable.current = el ?? null; if (buttonRef && 'current' in buttonRef) { - buttonRef.current = el; + buttonRef.current = el ?? null; } }} accessibilityLabel={accessibilityLabel} diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 33dfd2b920a9..7d2f99f49593 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Dimensions} from 'react-native'; -import type {EmitterSubscription, GestureResponderEvent} from 'react-native'; +import type {EmitterSubscription, GestureResponderEvent, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu'; @@ -69,8 +69,8 @@ function KYCWall({ walletTerms, shouldShowPersonalBankAccountOption = false, }: BaseKYCWallProps) { - const anchorRef = useRef(null); - const transferBalanceButtonRef = useRef(null); + const anchorRef = useRef(null); + const transferBalanceButtonRef = useRef(null); const [shouldShowAddPaymentMenu, setShouldShowAddPaymentMenu] = useState(false); @@ -111,7 +111,7 @@ function KYCWall({ return; } - const buttonPosition = getClickedTargetLocation(transferBalanceButtonRef.current); + const buttonPosition = getClickedTargetLocation(transferBalanceButtonRef.current as HTMLDivElement); const position = getAnchorPosition(buttonPosition); setPositionAddPaymentMenu(position); @@ -162,7 +162,7 @@ function KYCWall({ } // Use event target as fallback if anchorRef is null for safety - const targetElement = anchorRef.current ?? (event?.currentTarget as HTMLElement); + const targetElement = anchorRef.current ?? (event?.currentTarget as HTMLDivElement); transferBalanceButtonRef.current = targetElement; @@ -181,7 +181,7 @@ function KYCWall({ return; } - const clickedElementLocation = getClickedTargetLocation(targetElement); + const clickedElementLocation = getClickedTargetLocation(targetElement as HTMLDivElement); const position = getAnchorPosition(clickedElementLocation); setPositionAddPaymentMenu(position); diff --git a/src/components/OnyxProvider.tsx b/src/components/OnyxProvider.tsx index d14aec90fa10..0bc9130ea4a8 100644 --- a/src/components/OnyxProvider.tsx +++ b/src/components/OnyxProvider.tsx @@ -8,8 +8,8 @@ import createOnyxContext from './createOnyxContext'; const [withNetwork, NetworkProvider, NetworkContext] = createOnyxContext(ONYXKEYS.NETWORK); const [withPersonalDetails, PersonalDetailsProvider, , usePersonalDetails] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST); const [withCurrentDate, CurrentDateProvider] = createOnyxContext(ONYXKEYS.CURRENT_DATE); -const [withReportActionsDrafts, ReportActionsDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS); -const [withBlockedFromConcierge, BlockedFromConciergeProvider] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); +const [withReportActionsDrafts, ReportActionsDraftsProvider, , useReportActionsDrafts] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS); +const [withBlockedFromConcierge, BlockedFromConciergeProvider, , useBlockedFromConcierge] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [withBetas, BetasProvider, BetasContext, useBetas] = createOnyxContext(ONYXKEYS.BETAS); const [withReportCommentDrafts, ReportCommentDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [withPreferredTheme, PreferredThemeProvider, PreferredThemeContext] = createOnyxContext(ONYXKEYS.PREFERRED_THEME); @@ -66,5 +66,7 @@ export { useFrequentlyUsedEmojis, withPreferredEmojiSkinTone, PreferredEmojiSkinToneContext, + useBlockedFromConcierge, + useReportActionsDrafts, useSession, }; diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index e06037f47b63..314c1ba141c3 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -1,5 +1,6 @@ import type {RefObject} from 'react'; -import type {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {Text, View} from 'react-native'; import type {PopoverAnchorPosition} from '@components/Modal/types'; import type BaseModalProps from '@components/Modal/types'; import type {WindowDimensionsProps} from '@components/withWindowDimensions/types'; @@ -20,7 +21,7 @@ type PopoverProps = BaseModalProps & anchorAlignment?: AnchorAlignment; /** The anchor ref of the popover */ - anchorRef: RefObject; + anchorRef: RefObject; /** Whether disable the animations */ disableAnimation?: boolean; diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index 67481b41d50b..cc6c84477525 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -1,6 +1,7 @@ import type {RefObject} from 'react'; import React, {createContext, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {Text, View} from 'react-native'; import type {AnchorRef, PopoverContextProps, PopoverContextValue} from './types'; const PopoverContext = createContext({ @@ -10,7 +11,7 @@ const PopoverContext = createContext({ isOpen: false, }); -function elementContains(ref: RefObject | undefined, target: EventTarget | null) { +function elementContains(ref: RefObject | undefined, target: EventTarget | null) { if (ref?.current && 'contains' in ref.current && ref?.current?.contains(target as Node)) { return true; } @@ -21,7 +22,7 @@ function PopoverContextProvider(props: PopoverContextProps) { const [isOpen, setIsOpen] = useState(false); const activePopoverRef = useRef(null); - const closePopover = useCallback((anchorRef?: RefObject): boolean => { + const closePopover = useCallback((anchorRef?: RefObject): boolean => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { return false; } diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts index 2a366ae2a712..5022aee0f843 100644 --- a/src/components/PopoverProvider/types.ts +++ b/src/components/PopoverProvider/types.ts @@ -1,5 +1,6 @@ import type {ReactNode, RefObject} from 'react'; -import type {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {Text, View} from 'react-native'; type PopoverContextProps = { children: ReactNode; @@ -8,14 +9,14 @@ type PopoverContextProps = { type PopoverContextValue = { onOpen?: (popoverParams: AnchorRef) => void; popover?: AnchorRef | Record | null; - close: (anchorRef?: RefObject) => void; + close: (anchorRef?: RefObject) => void; isOpen: boolean; }; type AnchorRef = { - ref: RefObject; - close: (anchorRef?: RefObject) => void; - anchorRef: RefObject; + ref: RefObject; + close: (anchorRef?: RefObject) => void; + anchorRef: RefObject; }; export type {PopoverContextProps, PopoverContextValue, AnchorRef}; diff --git a/src/components/PopoverWithoutOverlay/types.ts b/src/components/PopoverWithoutOverlay/types.ts index 0d24cdd4bd9f..8fe40119ca61 100644 --- a/src/components/PopoverWithoutOverlay/types.ts +++ b/src/components/PopoverWithoutOverlay/types.ts @@ -1,5 +1,6 @@ import type {RefObject} from 'react'; -import type {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {Text, View} from 'react-native'; import type BaseModalProps from '@components/Modal/types'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -14,7 +15,7 @@ type PopoverWithoutOverlayProps = ChildrenProps & }; /** The anchor ref of the popover */ - anchorRef: RefObject; + anchorRef: RefObject; /** A react-native-animatable animation timing for the modal display animation */ animationInTiming?: number; diff --git a/src/components/Pressable/GenericPressable/types.ts b/src/components/Pressable/GenericPressable/types.ts index 2dd2e17e0454..9040a844e5a7 100644 --- a/src/components/Pressable/GenericPressable/types.ts +++ b/src/components/Pressable/GenericPressable/types.ts @@ -1,5 +1,6 @@ import type {ElementRef, ForwardedRef, RefObject} from 'react'; -import type {GestureResponderEvent, HostComponent, PressableStateCallbackType, PressableProps as RNPressableProps, StyleProp, View, ViewStyle} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, HostComponent, PressableStateCallbackType, PressableProps as RNPressableProps, Text as RNText, StyleProp, View, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import type {Shortcut} from '@libs/KeyboardShortcut'; import type CONST from '@src/CONST'; @@ -138,7 +139,7 @@ type PressableProps = RNPressableProps & noDragArea?: boolean; }; -type PressableRef = ForwardedRef; +type PressableRef = ForwardedRef; export default PressableProps; export type {PressableRef}; diff --git a/src/components/PressableWithSecondaryInteraction/types.ts b/src/components/PressableWithSecondaryInteraction/types.ts index aa67d45d66fb..b07c867daeb3 100644 --- a/src/components/PressableWithSecondaryInteraction/types.ts +++ b/src/components/PressableWithSecondaryInteraction/types.ts @@ -4,7 +4,7 @@ import type {ParsableStyle} from '@styles/utils/types'; type PressableWithSecondaryInteractionProps = PressableWithFeedbackProps & { /** The function that should be called when this pressable is pressed */ - onPress: (event?: GestureResponderEvent) => void; + onPress?: (event?: GestureResponderEvent) => void; /** The function that should be called when this pressable is pressedIn */ onPressIn?: (event?: GestureResponderEvent) => void; diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.tsx b/src/components/Reactions/ReportActionItemEmojiReactions.tsx index 7e95ab670b7e..c6bf4f9e4016 100644 --- a/src/components/Reactions/ReportActionItemEmojiReactions.tsx +++ b/src/components/Reactions/ReportActionItemEmojiReactions.tsx @@ -23,7 +23,7 @@ type ReportActionItemEmojiReactionsProps = WithCurrentUserPersonalDetailsProps & emojiReactions: OnyxEntry; /** The user's preferred locale. */ - preferredLocale: OnyxEntry; + preferredLocale?: OnyxEntry; /** The report action that these reactions are for */ reportAction: ReportAction; @@ -155,7 +155,7 @@ function ReportActionItemEmojiReactions({ shouldDisableOpacity={!!reportAction.pendingAction} > (popoverReactionListAnchors.current[reaction.reactionEmojiName] = ref)} + ref={(ref) => (popoverReactionListAnchors.current[reaction.reactionEmojiName] = ref ?? null)} count={reaction.reactionCount} emojiCodes={reaction.emojiCodes} onPress={reaction.onPress} diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index f0cd8dc1b4b5..1a740d51a2af 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -2,6 +2,7 @@ import Str from 'expensify-common/lib/str'; import React, {useMemo} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -26,7 +27,7 @@ type MoneyReportViewProps = { report: Report; /** Policy that the report belongs to */ - policy: Policy; + policy: OnyxEntry; /** Policy report fields */ policyReportFields: PolicyReportField[]; diff --git a/src/components/createOnyxContext.tsx b/src/components/createOnyxContext.tsx index 50cda00b17b4..c19b8006c86c 100644 --- a/src/components/createOnyxContext.tsx +++ b/src/components/createOnyxContext.tsx @@ -3,7 +3,7 @@ import type {ComponentType, ForwardedRef, ForwardRefExoticComponent, PropsWithou import React, {createContext, forwardRef, useContext} from 'react'; import {withOnyx} from 'react-native-onyx'; import getComponentDisplayName from '@libs/getComponentDisplayName'; -import type {OnyxKey, OnyxValue, OnyxValues} from '@src/ONYXKEYS'; +import type {OnyxKey, OnyxValue} from '@src/ONYXKEYS'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; // Provider types @@ -32,11 +32,11 @@ type CreateOnyxContext = [ WithOnyxKey, ComponentType, TOnyxKey>>, React.Context>, - () => OnyxValues[TOnyxKey], + () => NonNullable>, ]; export default (onyxKeyName: TOnyxKey): CreateOnyxContext => { - const Context = createContext>(null); + const Context = createContext>(null as OnyxValue); function Provider(props: ProviderPropsWithOnyx): ReactNode { return {props.children}; } @@ -86,7 +86,7 @@ export default (onyxKeyName: TOnyxKey): CreateOnyxCont if (value === null) { throw new Error(`useOnyxContext must be used within a OnyxProvider [key: ${onyxKeyName}]`); } - return value; + return value as NonNullable>; }; return [withOnyxKey, ProviderWithOnyx, Context, useOnyxContext]; diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index cab0f48d75fd..33cda171f24b 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -242,9 +242,13 @@ function getFrequentlyUsedEmojis(newEmoji: Emoji | Emoji[]): FrequentlyUsedEmoji /** * Given an emoji item object, return an emoji code based on its type. */ -const getEmojiCodeWithSkinColor = (item: Emoji, preferredSkinToneIndex: number): string => { +const getEmojiCodeWithSkinColor = (item: Emoji, preferredSkinToneIndex: OnyxEntry): string | undefined => { const {code, types} = item; - if (types?.[preferredSkinToneIndex]) { + if (!preferredSkinToneIndex) { + return; + } + + if (typeof preferredSkinToneIndex === 'number' && types?.[preferredSkinToneIndex]) { return types[preferredSkinToneIndex]; } @@ -305,7 +309,7 @@ function getAddedEmojis(currentEmojis: Emoji[], formerEmojis: Emoji[]): Emoji[] * Replace any emoji name in a text with the emoji icon. * If we're on mobile, we also add a space after the emoji granted there's no text after it. */ -function replaceEmojis(text: string, preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: Locale = CONST.LOCALES.DEFAULT): ReplacedEmoji { +function replaceEmojis(text: string, preferredSkinTone: OnyxEntry = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: Locale = CONST.LOCALES.DEFAULT): ReplacedEmoji { // emojisTrie is importing the emoji JSON file on the app starting and we want to avoid it const emojisTrie = require('./EmojiTrie').default; @@ -345,9 +349,9 @@ function replaceEmojis(text: string, preferredSkinTone: number = CONST.EMOJI_DEF // Set the cursor to the end of the last replaced Emoji. Note that we position after // the extra space, if we added one. - cursorPosition = newText.indexOf(emoji) + emojiReplacement.length; + cursorPosition = newText.indexOf(emoji) + (emojiReplacement?.length ?? 0); - newText = newText.replace(emoji, emojiReplacement); + newText = newText.replace(emoji, emojiReplacement ?? ''); } } @@ -369,7 +373,7 @@ function replaceEmojis(text: string, preferredSkinTone: number = CONST.EMOJI_DEF /** * Find all emojis in a text and replace them with their code. */ -function replaceAndExtractEmojis(text: string, preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: Locale = CONST.LOCALES.DEFAULT): ReplacedEmoji { +function replaceAndExtractEmojis(text: string, preferredSkinTone: OnyxEntry = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: Locale = CONST.LOCALES.DEFAULT): ReplacedEmoji { const {text: convertedText = '', emojis = [], cursorPosition} = replaceEmojis(text, preferredSkinTone, lang); return { diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 20313ee8912d..784d339a4a0d 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -180,3 +180,5 @@ export { getMicroSecondOnyxErrorObject, isReceiptError, }; + +export type {OnyxDataWithErrors}; diff --git a/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts b/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts index 9c7e6402d69b..82410b120df2 100644 --- a/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts +++ b/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts @@ -1,6 +1,7 @@ -import type {OnyxValue} from '@src/ONYXKEYS'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {Report} from '@src/types/onyx'; -export default function reportWithoutHasDraftSelector(report: OnyxValue<'report_'>) { +export default function reportWithoutHasDraftSelector(report: OnyxEntry) { if (!report) { return report; } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 0b6c9671d69c..572772858336 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -832,7 +832,7 @@ function hasRequestFromCurrentAccount(reportID: string, currentAccountID: number * Checks if a given report action corresponds to an actionable mention whisper. * @param reportAction */ -function isActionableMentionWhisper(reportAction: OnyxEntry): boolean { +function isActionableMentionWhisper(reportAction: OnyxEntry): reportAction is ReportActionBase & OriginalMessageActionableMentionWhisper { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLEMENTIONWHISPER; } diff --git a/src/libs/calculateAnchorPosition.ts b/src/libs/calculateAnchorPosition.ts index 3dc5924d023a..9fe6e8f018d8 100644 --- a/src/libs/calculateAnchorPosition.ts +++ b/src/libs/calculateAnchorPosition.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-imports */ -import type {Text as RNText, View} from 'react-native'; import type {ValueOf} from 'type-fest'; +import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; @@ -13,9 +13,9 @@ type AnchorOrigin = { /** * Gets the x,y position of the passed in component for the purpose of anchoring another component to it. */ -export default function calculateAnchorPosition(anchorComponent: View | RNText, anchorOrigin?: AnchorOrigin): Promise { +export default function calculateAnchorPosition(anchorComponent: ContextMenuAnchor, anchorOrigin?: AnchorOrigin): Promise { return new Promise((resolve) => { - if (!anchorComponent) { + if (!anchorComponent || !('measureInWindow' in anchorComponent)) { resolve({horizontal: 0, vertical: 0}); return; } diff --git a/src/libs/focusTextInputAfterAnimation/index.android.ts b/src/libs/focusTextInputAfterAnimation/index.android.ts index 31c748f5daa4..cca8a6588103 100644 --- a/src/libs/focusTextInputAfterAnimation/index.android.ts +++ b/src/libs/focusTextInputAfterAnimation/index.android.ts @@ -19,7 +19,7 @@ import type FocusTextInputAfterAnimation from './types'; */ const focusTextInputAfterAnimation: FocusTextInputAfterAnimation = (inputRef, animationLength = 0) => { setTimeout(() => { - inputRef.focus(); + inputRef?.focus(); }, animationLength); }; diff --git a/src/libs/focusTextInputAfterAnimation/index.ts b/src/libs/focusTextInputAfterAnimation/index.ts index 3f7c6555b5ce..66d0c35c1a63 100644 --- a/src/libs/focusTextInputAfterAnimation/index.ts +++ b/src/libs/focusTextInputAfterAnimation/index.ts @@ -4,7 +4,7 @@ import type FocusTextInputAfterAnimation from './types'; * This library is a no-op for all platforms except for Android and iOS and will immediately focus the given input without any delays. */ const focusTextInputAfterAnimation: FocusTextInputAfterAnimation = (inputRef) => { - inputRef.focus(); + inputRef?.focus(); }; export default focusTextInputAfterAnimation; diff --git a/src/libs/focusTextInputAfterAnimation/types.ts b/src/libs/focusTextInputAfterAnimation/types.ts index a6a14165598b..bfe29317c1ef 100644 --- a/src/libs/focusTextInputAfterAnimation/types.ts +++ b/src/libs/focusTextInputAfterAnimation/types.ts @@ -1,5 +1,5 @@ import type {TextInput} from 'react-native'; -type FocusTextInputAfterAnimation = (inputRef: TextInput | HTMLInputElement, animationLength: number) => void; +type FocusTextInputAfterAnimation = (inputRef: TextInput | HTMLInputElement | undefined, animationLength: number) => void; export default FocusTextInputAfterAnimation; diff --git a/src/libs/getClickedTargetLocation/types.ts b/src/libs/getClickedTargetLocation/types.ts index 7b1e85e63b17..eed10238be2d 100644 --- a/src/libs/getClickedTargetLocation/types.ts +++ b/src/libs/getClickedTargetLocation/types.ts @@ -1,5 +1,5 @@ type DOMRectProperties = 'top' | 'bottom' | 'left' | 'right' | 'height' | 'x' | 'y'; -type GetClickedTargetLocation = (target: Element) => Pick; +type GetClickedTargetLocation = (target: HTMLDivElement) => Pick; export default GetClickedTargetLocation; diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts index fd03adcffd93..330ba4470097 100644 --- a/src/libs/isReportMessageAttachment.ts +++ b/src/libs/isReportMessageAttachment.ts @@ -8,15 +8,15 @@ import type {Message} from '@src/types/onyx/ReportAction'; * * @param reportActionMessage report action's message as text, html and translationKey */ -export default function isReportMessageAttachment({text, html, translationKey}: Message): boolean { - if (!text || !html) { +export default function isReportMessageAttachment(message: Message | undefined): boolean { + if (!message?.text || !message.html) { return false; } - if (translationKey && text === CONST.ATTACHMENT_MESSAGE_TEXT) { - return translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT; + if (message.translationKey && message.text === CONST.ATTACHMENT_MESSAGE_TEXT) { + return message?.translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT; } const regex = new RegExp(` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="(.*)"`, 'i'); - return (text === CONST.ATTACHMENT_MESSAGE_TEXT || !!Str.isVideo(text)) && (!!html.match(regex) || html === CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML); + return (message.text === CONST.ATTACHMENT_MESSAGE_TEXT || !!Str.isVideo(message.text)) && (!!message.html.match(regex) || message.html === CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML); } diff --git a/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts b/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts index dbf2829a6c28..c8ef72ca15e7 100644 --- a/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts +++ b/src/libs/migrations/KeyReportActionsDraftByReportActionID.ts @@ -47,6 +47,7 @@ export default function () { // If newReportActionsDrafts[newOnyxKey] isn't set, fall back on the migrated draft if there is one const currentActionsDrafts = newReportActionsDrafts[newOnyxKey] ?? allReportActionsDrafts[newOnyxKey]; + newReportActionsDrafts[newOnyxKey] = { ...currentActionsDrafts, [reportActionID]: reportActionDraft, diff --git a/src/pages/home/ReportScreenContext.ts b/src/pages/home/ReportScreenContext.ts index e9440ab932d6..6f177098c2c4 100644 --- a/src/pages/home/ReportScreenContext.ts +++ b/src/pages/home/ReportScreenContext.ts @@ -1,8 +1,9 @@ import type {RefObject, SyntheticEvent} from 'react'; import {createContext} from 'react'; -import type {FlatList, GestureResponderEvent, View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {FlatList, GestureResponderEvent, Text, View} from 'react-native'; -type ReactionListAnchor = View | HTMLDivElement | null; +type ReactionListAnchor = View | Text | HTMLDivElement | null; type ReactionListEvent = GestureResponderEvent | MouseEvent | SyntheticEvent; diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 4f6e0548eb72..974a8824f5ff 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -22,7 +22,7 @@ import type {Beta, ReportAction, ReportActions} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {ContextMenuAction, ContextMenuActionPayload} from './ContextMenuActions'; import ContextMenuActions from './ContextMenuActions'; -import type {ContextMenuType} from './ReportActionContextMenu'; +import type {ContextMenuAnchor, ContextMenuType} from './ReportActionContextMenu'; import {hideContextMenu, showContextMenu} from './ReportActionContextMenu'; type BaseReportActionContextMenuOnyxProps = { @@ -64,7 +64,7 @@ type BaseReportActionContextMenuProps = BaseReportActionContextMenuOnyxProps & { type?: ContextMenuType; /** Target node which is the target of ContentMenu */ - anchor?: MutableRefObject; + anchor?: MutableRefObject; /** Flag to check if the chat participant is Chronos */ isChronosReport?: boolean; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 2ebe08573066..b836d73a9371 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -1,7 +1,8 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import type {MutableRefObject} from 'react'; import React from 'react'; -import type {GestureResponderEvent} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, Text, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -29,6 +30,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type {Beta, ReportAction, ReportActionReactions, Report as ReportType} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {ContextMenuAnchor} from './ReportActionContextMenu'; import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; /** Gets the HTML version of the message in an action */ @@ -53,7 +55,7 @@ type ShouldShow = ( reportAction: OnyxEntry, isArchivedRoom: boolean, betas: OnyxEntry, - menuTarget: MutableRefObject | undefined, + menuTarget: MutableRefObject | undefined, isChronosReport: boolean, reportID: string, isPinnedChat: boolean, @@ -70,6 +72,8 @@ type ContextMenuActionPayload = { close: () => void; openContextMenu: () => void; interceptAnonymousUser: (callback: () => void, isAnonymousAction?: boolean) => void; + anchor?: MutableRefObject; + checkIfContextMenuActive?: () => void; openOverflowMenu: (event: GestureResponderEvent | MouseEvent) => void; event?: GestureResponderEvent | MouseEvent | KeyboardEvent; setIsEmojiPickerActive?: (state: boolean) => void; @@ -404,7 +408,7 @@ const ContextMenuActions: ContextMenuAction[] = [ const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); // Only hide the copylink menu item when context menu is opened over img element. - const isAttachmentTarget = menuTarget?.current?.tagName === 'IMG' && isAttachment; + const isAttachmentTarget = menuTarget?.current && 'tagName' in menuTarget.current && menuTarget?.current.tagName === 'IMG' && isAttachment; return Permissions.canUseCommentLinking(betas) && type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget && !ReportActionsUtils.isMessageDeleted(reportAction); }, onPress: (closePopover, {reportAction, reportID}) => { diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts index 98b38dcb6968..b7c3d6214094 100644 --- a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts +++ b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts @@ -1,6 +1,6 @@ import type {BaseReportActionContextMenuProps} from '@pages/home/report/ContextMenu/BaseReportActionContextMenu'; -type MiniReportActionContextMenuProps = Omit & { +type MiniReportActionContextMenuProps = Omit & { /** Should the reportAction this menu is attached to have the appearance of being grouped with the previous reportAction? */ displayAsGroup?: boolean; }; diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx index 862d5f01c2fc..931b87704ce5 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -67,8 +67,8 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef(null); const anchorRef = useRef(null); const dimensionsEventListener = useRef(null); - const contextMenuAnchorRef = useRef(null); - const contextMenuTargetNode = useRef(null); + const contextMenuAnchorRef = useRef(null); + const contextMenuTargetNode = useRef(null); const onPopoverShow = useRef(() => {}); const onPopoverHide = useRef(() => {}); @@ -83,7 +83,7 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef new Promise((resolve) => { - if (contextMenuAnchorRef.current && typeof contextMenuAnchorRef.current.measureInWindow === 'function') { + 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}); @@ -169,7 +169,7 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef { const {pageX = 0, pageY = 0} = extractPointerEvent(event); contextMenuAnchorRef.current = contextMenuAnchor; - contextMenuTargetNode.current = event.target as HTMLElement; + contextMenuTargetNode.current = event.target as HTMLDivElement; if (shouldCloseOnTarget) { anchorRef.current = event.target as HTMLDivElement; } else { diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts index f2537c56a5af..21c1eea18e03 100644 --- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts @@ -16,7 +16,7 @@ type OnCancel = () => void; type ContextMenuType = ValueOf; -type ContextMenuAnchor = View | RNText | null | undefined; +type ContextMenuAnchor = View | RNText | HTMLDivElement | null | undefined; type ShowContextMenu = ( type: ContextMenuType, diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.tsx similarity index 53% rename from src/pages/home/report/ReportActionItem.js rename to src/pages/home/report/ReportActionItem.tsx index 12588845214b..11dd23e5284a 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.tsx @@ -1,9 +1,10 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; +import lodashIsEqual from 'lodash/isEqual'; import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import type {GestureResponderEvent, TextInput} from 'react-native'; import {InteractionManager, View} from 'react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {Emoji} from '@assets/emojis/types'; import Button from '@components/Button'; import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; @@ -12,11 +13,11 @@ import * as Expensicons from '@components/Icon/Expensicons'; import InlineSystemMessage from '@components/InlineSystemMessage'; import KYCWall from '@components/KYCWall'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {usePersonalDetails, withBlockedFromConcierge, withNetwork, withReportActionsDrafts} from '@components/OnyxProvider'; +import {useBlockedFromConcierge, usePersonalDetails, useReportActionsDrafts} from '@components/OnyxProvider'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; -import EmojiReactionsPropTypes from '@components/Reactions/EmojiReactionsPropTypes'; import ReportActionItemEmojiReactions from '@components/Reactions/ReportActionItemEmojiReactions'; import RenderHTML from '@components/RenderHTML'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; import ChronosOOOListActions from '@components/ReportActionItem/ChronosOOOListActions'; import MoneyReportView from '@components/ReportActionItem/MoneyReportView'; @@ -30,14 +31,13 @@ import TaskView from '@components/ReportActionItem/TaskView'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; import UnreadActionIndicator from '@components/UnreadActionIndicator'; -import withLocalize from '@components/withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; +import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import ControlSelection from '@libs/ControlSelection'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -49,9 +49,7 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import SelectionScraper from '@libs/SelectionScraper'; -import userWalletPropTypes from '@pages/EnablePayments/userWalletPropTypes'; import {ReactionListContext} from '@pages/home/ReportScreenContext'; -import reportPropTypes from '@pages/reportPropTypes'; import * as BankAccounts from '@userActions/BankAccounts'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import * as store from '@userActions/ReimbursementAccount/store'; @@ -62,6 +60,8 @@ import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMenu'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; @@ -75,88 +75,113 @@ import ReportActionItemMessage from './ReportActionItemMessage'; import ReportActionItemMessageEdit from './ReportActionItemMessageEdit'; import ReportActionItemSingle from './ReportActionItemSingle'; import ReportActionItemThread from './ReportActionItemThread'; -import reportActionPropTypes from './reportActionPropTypes'; import ReportAttachmentsContext from './ReportAttachmentsContext'; -const propTypes = { - ...windowDimensionsPropTypes, +const getDraftMessage = (drafts: OnyxCollection, reportID: string, action: OnyxTypes.ReportAction): string | undefined => { + const originalReportID = ReportUtils.getOriginalReportID(reportID, action); + const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`; + const draftMessage = drafts?.[draftKey]?.[action.reportActionID]; + return typeof draftMessage === 'string' ? draftMessage : draftMessage?.message; +}; + +type ReportActionItemOnyxProps = { + /** Stores user's preferred skin tone */ + preferredSkinTone: OnyxEntry; + + /** IOU report for this action, if any */ + iouReport: OnyxEntry; + + emojiReactions: OnyxEntry; + + /** The user's wallet account */ + userWallet: OnyxEntry; + + /** All the report actions belonging to the report's parent */ + parentReportActions: OnyxEntry; + + /** All policy report fields */ + policyReportFields: OnyxEntry; + /** The policy which the user has access to and which the report is tied to */ + policy: OnyxEntry; +}; + +type ReportActionItemProps = { /** Report for this action */ - report: reportPropTypes.isRequired, + report: OnyxTypes.Report; /** All the data of the action item */ - action: PropTypes.shape(reportActionPropTypes).isRequired, + action: OnyxTypes.ReportAction; /** Should the comment have the appearance of being grouped with the previous comment? */ - displayAsGroup: PropTypes.bool.isRequired, + displayAsGroup: boolean; /** Is this the most recent IOU Action? */ - isMostRecentIOUReportAction: PropTypes.bool.isRequired, + isMostRecentIOUReportAction: boolean; /** Should we display the new marker on top of the comment? */ - shouldDisplayNewMarker: PropTypes.bool.isRequired, + shouldDisplayNewMarker: boolean; /** Determines if the avatar is displayed as a subscript (positioned lower than normal) */ - shouldShowSubscriptAvatar: PropTypes.bool, + shouldShowSubscriptAvatar?: boolean; /** Position index of the report action in the overall report FlatList view */ - index: PropTypes.number.isRequired, - - /** Draft message - if this is set the comment is in 'edit' mode */ - draftMessage: PropTypes.string, - - /** Stores user's preferred skin tone */ - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - - emojiReactions: EmojiReactionsPropTypes, - - /** IOU report for this action, if any */ - iouReport: reportPropTypes, + index: number; /** Flag to show, hide the thread divider line */ - shouldHideThreadDividerLine: PropTypes.bool, + shouldHideThreadDividerLine?: boolean; - /** The user's wallet account */ - userWallet: userWalletPropTypes, - - /** All the report actions belonging to the report's parent */ - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + linkedReportActionID?: string; /** Callback to be called on onPress */ - onPress: PropTypes.func, -}; - -const defaultProps = { - draftMessage: undefined, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - emojiReactions: {}, - shouldShowSubscriptAvatar: false, - iouReport: undefined, - shouldHideThreadDividerLine: false, - userWallet: {}, - parentReportActions: {}, - onPress: undefined, -}; - -function ReportActionItem(props) { + onPress?: () => void; +} & ReportActionItemOnyxProps; + +const isIOUReport = (actionObj: OnyxEntry): actionObj is OnyxTypes.ReportActionBase & OnyxTypes.OriginalMessageIOU => + actionObj?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU; + +function ReportActionItem({ + action, + report, + linkedReportActionID, + displayAsGroup, + emojiReactions, + index, + iouReport, + isMostRecentIOUReportAction, + parentReportActions, + preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, + shouldDisplayNewMarker, + userWallet, + shouldHideThreadDividerLine = false, + shouldShowSubscriptAvatar = false, + policyReportFields, + policy, + onPress = undefined, +}: ReportActionItemProps) { + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); + const blockedFromConcierge = useBlockedFromConcierge(); + const reportActionDrafts = useReportActionsDrafts(); + const draftMessage = useMemo(() => getDraftMessage(reportActionDrafts, report.reportID, action), [action, report.reportID, reportActionDrafts]); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; - const [isContextMenuActive, setIsContextMenuActive] = useState(() => ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)); - const [isEmojiPickerActive, setIsEmojiPickerActive] = useState(); + const [isContextMenuActive, setIsContextMenuActive] = useState(() => ReportActionContextMenu.isActiveReportAction(action.reportActionID)); + const [isEmojiPickerActive, setIsEmojiPickerActive] = useState(); const [isHidden, setIsHidden] = useState(false); - const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED); + const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED); const reactionListRef = useContext(ReactionListContext); const {updateHiddenAttachments} = useContext(ReportAttachmentsContext); - const textInputRef = useRef(); - const popoverAnchorRef = useRef(); - const downloadedPreviews = useRef([]); - const prevDraftMessage = usePrevious(props.draftMessage); - const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action); - const originalReport = props.report.reportID === originalReportID ? props.report : ReportUtils.getReport(originalReportID); - const isReportActionLinked = props.linkedReportActionID && props.action.reportActionID && props.linkedReportActionID === props.action.reportActionID; + const textInputRef = useRef(); + const popoverAnchorRef = useRef(null); + const downloadedPreviews = useRef([]); + const prevDraftMessage = usePrevious(draftMessage); + const originalReportID = ReportUtils.getOriginalReportID(report.reportID, action); + const originalReport = report.reportID === originalReportID ? report : ReportUtils.getReport(originalReportID); + const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; const reportScrollManager = useReportScrollManager(); @@ -164,117 +189,120 @@ function ReportActionItem(props) { () => (isReportActionLinked ? StyleUtils.getBackgroundColorStyle(theme.hoverComponentBG) : {}), [StyleUtils, isReportActionLinked, theme.hoverComponentBG], ); - const originalMessage = lodashGet(props.action, 'originalMessage', {}); - const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(props.action); - const prevActionResolution = usePrevious(lodashGet(props.action, 'originalMessage.resolution', null)); + + const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(action); + const prevActionResolution = usePrevious(ReportActionsUtils.isActionableMentionWhisper(action) ? action.originalMessage.resolution : null); // IOUDetails only exists when we are sending money - const isSendingMoney = originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && _.has(originalMessage, 'IOUDetails'); + const isSendingMoney = isIOUReport(action) && action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && action.originalMessage.IOUDetails; const updateHiddenState = useCallback( - (isHiddenValue) => { + (isHiddenValue: boolean) => { setIsHidden(isHiddenValue); - const isAttachment = ReportUtils.isReportMessageAttachment(_.last(props.action.message)); + const isAttachment = ReportUtils.isReportMessageAttachment(action.message?.at(-1)); if (!isAttachment) { return; } - updateHiddenAttachments(props.action.reportActionID, isHiddenValue); + updateHiddenAttachments(action.reportActionID, isHiddenValue); }, - [props.action.reportActionID, props.action.message, updateHiddenAttachments], + [action.reportActionID, action.message, updateHiddenAttachments], ); useEffect( () => () => { // ReportActionContextMenu, EmojiPicker and PopoverReactionList are global components, // we should also hide them when the current component is destroyed - if (ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)) { + if (ReportActionContextMenu.isActiveReportAction(action.reportActionID)) { ReportActionContextMenu.hideContextMenu(); ReportActionContextMenu.hideDeleteModal(); } - if (EmojiPickerAction.isActive(props.action.reportActionID)) { + if (EmojiPickerAction.isActive(action.reportActionID)) { EmojiPickerAction.hideEmojiPicker(true); } - if (reactionListRef.current && reactionListRef.current.isActiveReportAction(props.action.reportActionID)) { - reactionListRef.current.hideReactionList(); + if (reactionListRef?.current?.isActiveReportAction(action.reportActionID)) { + reactionListRef?.current?.hideReactionList(); } }, - [props.action.reportActionID, reactionListRef], + [action.reportActionID, reactionListRef], ); useEffect(() => { // We need to hide EmojiPicker when this is a deleted parent action - if (!isDeletedParentAction || !EmojiPickerAction.isActive(props.action.reportActionID)) { + if (!isDeletedParentAction || !EmojiPickerAction.isActive(action.reportActionID)) { return; } EmojiPickerAction.hideEmojiPicker(true); - }, [isDeletedParentAction, props.action.reportActionID]); + }, [isDeletedParentAction, action.reportActionID]); useEffect(() => { - if (!_.isUndefined(prevDraftMessage) || _.isUndefined(props.draftMessage)) { + if (prevDraftMessage !== undefined || draftMessage === undefined) { return; } focusTextInputAfterAnimation(textInputRef.current, 100); - }, [prevDraftMessage, props.draftMessage]); + }, [prevDraftMessage, draftMessage]); useEffect(() => { if (!Permissions.canUseLinkPreviews()) { return; } - const urls = ReportActionsUtils.extractLinksFromMessageHtml(props.action); - if (_.isEqual(downloadedPreviews.current, urls) || props.action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + const urls = ReportActionsUtils.extractLinksFromMessageHtml(action); + if (lodashIsEqual(downloadedPreviews.current, urls) || action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return; } downloadedPreviews.current = urls; - Report.expandURLPreview(props.report.reportID, props.action.reportActionID); - }, [props.action, props.report.reportID]); + Report.expandURLPreview(report.reportID, action.reportActionID); + }, [action, report.reportID]); useEffect(() => { - if (_.isUndefined(props.draftMessage) || !ReportActionsUtils.isDeletedAction(props.action)) { + if (draftMessage === undefined || !ReportActionsUtils.isDeletedAction(action)) { return; } - Report.deleteReportActionDraft(props.report.reportID, props.action); - }, [props.draftMessage, props.action, props.report.reportID]); + Report.deleteReportActionDraft(report.reportID, action); + }, [draftMessage, action, report.reportID]); // Hide the message if it is being moderated for a higher offense, or is hidden by a moderator // Removed messages should not be shown anyway and should not need this flow - const latestDecision = lodashGet(props, ['action', 'message', 0, 'moderationDecision', 'decision'], ''); + const latestDecision = action.message?.[0].moderationDecision?.decision ?? ''; useEffect(() => { - if (props.action.actionName !== CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT) { + if (action.actionName !== CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT) { return; } // Hide reveal message button and show the message if latestDecision is changed to empty - if (_.isEmpty(latestDecision)) { + if (!latestDecision) { setModerationDecision(CONST.MODERATION.MODERATOR_DECISION_APPROVED); setIsHidden(false); return; } setModerationDecision(latestDecision); - if (!_.contains([CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING], latestDecision) && !ReportActionsUtils.isPendingRemove(props.action)) { + if ( + ![CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING].some((item) => item === latestDecision) && + !ReportActionsUtils.isPendingRemove(action) + ) { setIsHidden(true); return; } setIsHidden(false); - }, [latestDecision, props.action]); + }, [latestDecision, action]); const toggleContextMenuFromActiveReportAction = useCallback(() => { - setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)); - }, [props.action.reportActionID]); + setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(action.reportActionID)); + }, [action.reportActionID]); /** * Show the ReportActionContextMenu modal popover. * - * @param {Object} [event] - A press event. + * @param [event] - A press event. */ const showPopover = useCallback( - (event) => { + (event: GestureResponderEvent | MouseEvent) => { // Block menu on the message being Edited or if the report action item has errors - if (!_.isUndefined(props.draftMessage) || !_.isEmpty(props.action.errors)) { + if (draftMessage !== undefined || !isEmptyObject(action.errors)) { return; } @@ -284,11 +312,11 @@ function ReportActionItem(props) { CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, event, selection, - popoverAnchorRef, - props.report.reportID, - props.action.reportActionID, + popoverAnchorRef.current, + report.reportID, + action.reportActionID, originalReportID, - props.draftMessage, + draftMessage ?? '', () => setIsContextMenuActive(true), toggleContextMenuFromActiveReportAction, ReportUtils.isArchivedRoom(originalReport), @@ -297,168 +325,167 @@ function ReportActionItem(props) { false, [], false, - setIsEmojiPickerActive, + setIsEmojiPickerActive as () => void, ); }, - [props.draftMessage, props.action, props.report.reportID, toggleContextMenuFromActiveReportAction, originalReport, originalReportID], + [draftMessage, action, report.reportID, toggleContextMenuFromActiveReportAction, originalReport, originalReportID], ); // Handles manual scrolling to the bottom of the chat when the last message is an actionable mention whisper and it's resolved. // This fixes an issue where InvertedFlatList fails to auto scroll down and results in an empty space at the bottom of the chat in IOS. useEffect(() => { - if (props.index !== 0 || !ReportActionsUtils.isActionableMentionWhisper(props.action)) { + if (index !== 0 || !ReportActionsUtils.isActionableMentionWhisper(action)) { return; } - if (prevActionResolution !== lodashGet(props.action, 'originalMessage.resolution', null)) { - reportScrollManager.scrollToIndex(props.index); + if (ReportActionsUtils.isActionableMentionWhisper(action) && prevActionResolution !== (action.originalMessage.resolution ?? null)) { + reportScrollManager.scrollToIndex(index); } - }, [props.index, props.action, prevActionResolution, reportScrollManager]); + }, [index, action, prevActionResolution, reportScrollManager]); const toggleReaction = useCallback( - (emoji) => { - Report.toggleEmojiReaction(props.report.reportID, props.action, emoji, props.emojiReactions); + (emoji: Emoji) => { + Report.toggleEmojiReaction(report.reportID, action, emoji, emojiReactions); }, - [props.report, props.action, props.emojiReactions], + [report, action, emojiReactions], ); const contextValue = useMemo( () => ({ - anchor: popoverAnchorRef, - report: props.report, - action: props.action, + anchor: popoverAnchorRef.current, + report, + action, checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, }), - [props.report, props.action, toggleContextMenuFromActiveReportAction], + [report, action, toggleContextMenuFromActiveReportAction], ); - const actionableItemButtons = useMemo(() => { - if (!(ReportActionsUtils.isActionableMentionWhisper(props.action) && !lodashGet(props.action, 'originalMessage.resolution', null))) { + const actionableItemButtons: ActionableItem[] = useMemo(() => { + if (!(ReportActionsUtils.isActionableMentionWhisper(action) && (!action.originalMessage?.resolution ?? null))) { return []; } return [ { text: 'actionableMentionWhisperOptions.invite', - key: `${props.action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE}`, - onPress: () => Report.resolveActionableMentionWhisper(props.report.reportID, props.action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE), + key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE}`, + onPress: () => Report.resolveActionableMentionWhisper(report.reportID, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE), isPrimary: true, }, { text: 'actionableMentionWhisperOptions.nothing', - key: `${props.action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING}`, - onPress: () => Report.resolveActionableMentionWhisper(props.report.reportID, props.action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING), + key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING}`, + onPress: () => Report.resolveActionableMentionWhisper(report.reportID, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING), }, ]; - }, [props.action, props.report.reportID]); + }, [action, report.reportID]); /** * Get the content of ReportActionItem - * @param {Boolean} hovered whether the ReportActionItem is hovered - * @param {Boolean} isWhisper whether the report action is a whisper - * @param {Boolean} hasErrors whether the report action has any errors - * @returns {Object} child component(s) + * @param hovered whether the ReportActionItem is hovered + * @param isWhisper whether the report action is a whisper + * @param hasErrors whether the report action has any errors + * @returns child component(s) */ - const renderItemContent = (hovered = false, isWhisper = false, hasErrors = false) => { + const renderItemContent = (hovered = false, isWhisper = false, hasErrors = false): React.JSX.Element => { let children; // Show the MoneyRequestPreview for when request was created, bill was split or money was sent if ( - props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && - originalMessage && + isIOUReport(action) && + action.originalMessage && // For the pay flow, we only want to show MoneyRequestAction when sending money. When paying, we display a regular system message - (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || isSendingMoney) + (action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || isSendingMoney) ) { // There is no single iouReport for bill splits, so only 1:1 requests require an iouReportID - const iouReportID = originalMessage.IOUReportID ? originalMessage.IOUReportID.toString() : '0'; + const iouReportID = action.originalMessage.IOUReportID ? action.originalMessage.IOUReportID.toString() : '0'; children = ( ); - } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) { + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) { children = ( ); } else if ( - props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED || - props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED || - props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED + action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED || + action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED || + action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED ) { - children = ; - } else if (ReportActionsUtils.isCreatedTaskReportAction(props.action)) { + children = ; + } else if (ReportActionsUtils.isCreatedTaskReportAction(action)) { children = ( ); - } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED) { - const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(lodashGet(personalDetails, props.report.ownerAccountID)); - const paymentType = lodashGet(props.action, 'originalMessage.paymentType', ''); + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED) { + const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails[report.ownerAccountID ?? -1]); + const paymentType = action.originalMessage.paymentType ?? ''; - const isSubmitterOfUnsettledReport = ReportUtils.isCurrentUserSubmitter(props.report.reportID) && !ReportUtils.isSettled(props.report.reportID); + const isSubmitterOfUnsettledReport = ReportUtils.isCurrentUserSubmitter(report.reportID) && !ReportUtils.isSettled(report.reportID); const shouldShowAddCreditBankAccountButton = isSubmitterOfUnsettledReport && !store.hasCreditBankAccount() && paymentType !== CONST.IOU.PAYMENT_TYPE.EXPENSIFY; const shouldShowEnableWalletButton = - isSubmitterOfUnsettledReport && - (_.isEmpty(props.userWallet) || props.userWallet.tierName === CONST.WALLET.TIER_NAME.SILVER) && - paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY; + isSubmitterOfUnsettledReport && (isEmptyObject(userWallet) || userWallet?.tierName === CONST.WALLET.TIER_NAME.SILVER) && paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY; children = ( <> {shouldShowAddCreditBankAccountButton && ( )} @@ -508,49 +535,46 @@ function ReportActionItem(props) { for example: Invite a user mentioned but not a member of the room https://github.com/Expensify/App/issues/32741 */} - {actionableItemButtons.length > 0 && ( - - )} + {actionableItemButtons.length > 0 && } ) : ( )} ); } - const numberOfThreadReplies = _.get(props, ['action', 'childVisibleActionCount'], 0); + const numberOfThreadReplies = action.childVisibleActionCount ?? 0; - const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(props.action, props.report.reportID); - const oldestFourAccountIDs = _.map(lodashGet(props.action, 'childOldestFourAccountIDs', '').split(','), (accountID) => Number(accountID)); - const draftMessageRightAlign = !_.isUndefined(props.draftMessage) ? styles.chatItemReactionsDraftRight : {}; + const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(action, report.reportID); + const oldestFourAccountIDs = + action.childOldestFourAccountIDs + ?.split(',') + .map((accountID) => Number(accountID)) + .filter((accountID): accountID is number => typeof accountID === 'number') ?? []; + const draftMessageRightAlign = draftMessage !== undefined ? styles.chatItemReactionsDraftRight : {}; return ( <> {children} - {Permissions.canUseLinkPreviews() && !isHidden && !_.isEmpty(props.action.linkMetadata) && ( - - !_.isEmpty(item))} /> + {Permissions.canUseLinkPreviews() && !isHidden && (action.linkMetadata?.length ?? 0) > 0 && ( + + !isEmptyObject(item))} /> )} - {!ReportActionsUtils.isMessageDeleted(props.action) && ( + {!ReportActionsUtils.isMessageDeleted(action) && ( { if (Session.isAnonymousUser()) { @@ -571,9 +595,9 @@ function ReportActionItem(props) { {shouldDisplayThreadReplies && ( { + const renderReportActionItem = (hovered: boolean, isWhisper: boolean, hasErrors: boolean): React.JSX.Element => { const content = renderItemContent(hovered || isContextMenuActive || isEmojiPickerActive, isWhisper, hasErrors); - if (!_.isUndefined(props.draftMessage)) { + if (draftMessage !== undefined) { return {content}; } - if (!props.displayAsGroup) { + if (!displayAsGroup) { return ( item === moderationDecision) && + !ReportActionsUtils.isPendingRemove(action) } > {content} @@ -621,23 +645,23 @@ function ReportActionItem(props) { return {content}; }; - if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { - const parentReportAction = props.parentReportActions[props.report.parentReportActionID]; + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + const parentReportAction = parentReportActions?.[report.parentReportActionID ?? ''] ?? null; if (ReportActionsUtils.isTransactionThread(parentReportAction)) { const isReversedTransaction = ReportActionsUtils.isReversedTransaction(parentReportAction); if (ReportActionsUtils.isDeletedParentAction(parentReportAction) || isReversedTransaction) { return ( - + - - + + ${props.translate(isReversedTransaction ? 'parentReportAction.reversedTransaction' : 'parentReportAction.deletedRequest')}`} + html={`${translate(isReversedTransaction ? 'parentReportAction.reversedTransaction' : 'parentReportAction.deletedRequest')}`} /> @@ -649,25 +673,25 @@ function ReportActionItem(props) { return ( ); } - if (ReportUtils.isTaskReport(props.report)) { - if (ReportUtils.isCanceledTaskReport(props.report, parentReportAction)) { + if (ReportUtils.isTaskReport(report)) { + if (ReportUtils.isCanceledTaskReport(report, parentReportAction)) { return ( - + - - + + - ${props.translate('parentReportAction.deletedTask')}`} /> + ${translate('parentReportAction.deletedTask')}`} /> @@ -676,25 +700,25 @@ function ReportActionItem(props) { ); } return ( - + - + ); } - if (ReportUtils.isExpenseReport(props.report) || ReportUtils.isIOUReport(props.report)) { + if (ReportUtils.isExpenseReport(report) || ReportUtils.isIOUReport(report)) { return ( - + ); @@ -702,96 +726,94 @@ function ReportActionItem(props) { return ( ); } - if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { - return ; + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { + return ; } - if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CHRONOSOOOLIST) { + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CHRONOSOOOLIST) { return ( ); } // For the `pay` IOU action on non-send money flow, we don't want to render anything if `isWaitingOnBankAccount` is true // Otherwise, we will see two system messages informing the payee needs to add a bank account or wallet - if ( - props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && - lodashGet(props.report, 'isWaitingOnBankAccount', false) && - originalMessage && - originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && - !isSendingMoney - ) { + if (isIOUReport(action) && !!report?.isWaitingOnBankAccount && action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !isSendingMoney) { return null; } // if action is actionable mention whisper and resolved by user, then we don't want to render anything - if (ReportActionsUtils.isActionableMentionWhisper(props.action) && lodashGet(props.action, 'originalMessage.resolution', null)) { + if (ReportActionsUtils.isActionableMentionWhisper(action) && (action.originalMessage.resolution ?? null)) { return null; } // We currently send whispers to all report participants and hide them in the UI for users that shouldn't see them. // This is a temporary solution needed for comment-linking. // The long term solution will leverage end-to-end encryption and only targeted users will be able to decrypt. - if (ReportActionsUtils.isWhisperActionTargetedToOthers(props.action)) { + if (ReportActionsUtils.isWhisperActionTargetedToOthers(action)) { return null; } - const hasErrors = !_.isEmpty(props.action.errors); - const whisperedToAccountIDs = props.action.whisperedToAccountIDs || []; + const hasErrors = !isEmptyObject(action.errors); + const whisperedToAccountIDs = action.whisperedToAccountIDs ?? []; const isWhisper = whisperedToAccountIDs.length > 0; const isMultipleParticipant = whisperedToAccountIDs.length > 1; const isWhisperOnlyVisibleByUser = isWhisper && ReportUtils.isCurrentUserTheOnlyParticipant(whisperedToAccountIDs); - const whisperedToPersonalDetails = isWhisper ? _.filter(personalDetails, (details) => _.includes(whisperedToAccountIDs, details.accountID)) : []; + const whisperedToPersonalDetails = isWhisper + ? (Object.values(personalDetails ?? {}).filter((details) => whisperedToAccountIDs.includes(details?.accountID ?? -1)) as OnyxTypes.PersonalDetails[]) + : []; const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; return ( props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPress={onPress} + style={[action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? styles.pointerEventsNone : styles.pointerEventsAuto]} + onPressIn={() => isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} onSecondaryInteraction={showPopover} - preventDefaultContextMenu={_.isUndefined(props.draftMessage) && !hasErrors} + preventDefaultContextMenu={draftMessage === undefined && !hasErrors} withoutFocusOnSecondaryInteraction - accessibilityLabel={props.translate('accessibilityHints.chatMessage')} + accessibilityLabel={translate('accessibilityHints.chatMessage')} + accessible > {(hovered) => ( - {props.shouldDisplayNewMarker && } + {shouldDisplayNewMarker && } - + ReportActions.clearReportActionErrors(props.report.reportID, props.action)} + onClose={() => ReportActions.clearReportActionErrors(report.reportID, action)} + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing pendingAction={ - !_.isUndefined(props.draftMessage) ? null : props.action.pendingAction || (props.action.isOptimisticAction ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : '') + draftMessage !== undefined ? undefined : action.pendingAction ?? (action.isOptimisticAction ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : undefined) } - shouldHideOnDelete={!ReportActionsUtils.isThreadParentMessage(props.action, props.report.reportID)} - errors={ErrorUtils.getLatestErrorMessageField(props.action)} + shouldHideOnDelete={!ReportActionsUtils.isThreadParentMessage(action, report.reportID)} + errors={ErrorUtils.getLatestErrorMessageField(action as ErrorUtils.OnyxDataWithErrors)} errorRowStyles={[styles.ml10, styles.mr2]} - needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(props.action)} + needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(action)} shouldDisableStrikeThrough > {isWhisper && ( @@ -804,11 +826,11 @@ function ReportActionItem(props) { /> - {props.translate('reportActionContextMenu.onlyVisible')} + {translate('reportActionContextMenu.onlyVisible')}   )} - {renderReportActionItem(hovered || isReportActionLinked, isWhisper, hasErrors)} + {renderReportActionItem(!!hovered || !!isReportActionLinked, isWhisper, hasErrors)} )} - + {/* @ts-expect-error TODO check if there is a field on the reportAction object */} + ); } -ReportActionItem.propTypes = propTypes; -ReportActionItem.defaultProps = defaultProps; - -export default compose( - withWindowDimensions, - withLocalize, - withNetwork(), - withBlockedFromConcierge({propName: 'blockedFromConcierge'}), - withReportActionsDrafts({ - propName: 'draftMessage', - transformValue: (drafts, props) => { - const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action); - const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`; - return lodashGet(drafts, [draftKey, props.action.reportActionID, 'message']); +export default withOnyx({ + preferredSkinTone: { + key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE, + }, + iouReport: { + key: ({action}) => { + const iouReportID = ReportActionsUtils.getIOUReportIDFromReportActionPreview(action); + return `${ONYXKEYS.COLLECTION.REPORT}${iouReportID ?? ''}`; }, - }), - withOnyx({ - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE, - }, - iouReport: { - key: ({action}) => { - const iouReportID = ReportActionsUtils.getIOUReportIDFromReportActionPreview(action); - return iouReportID ? `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}` : undefined; - }, - initialValue: {}, - }, - policyReportFields: { - key: ({report}) => (report && 'policyID' in report ? `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${report.policyID}` : undefined), - initialValue: [], - }, - policy: { - key: ({report}) => (report && 'policyID' in report ? `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}` : undefined), - initialValue: {}, - }, - emojiReactions: { - key: ({action}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, - initialValue: {}, - }, - userWallet: { - key: ONYXKEYS.USER_WALLET, - }, - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID || 0}`, - canEvict: false, - }, - }), -)( + initialValue: {} as OnyxTypes.Report, + }, + policyReportFields: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${report.policyID ?? ''}`, + initialValue: {}, + }, + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID ?? ''}`, + initialValue: {} as OnyxTypes.Policy, + }, + emojiReactions: { + key: ({action}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`, + initialValue: {}, + }, + userWallet: { + key: ONYXKEYS.USER_WALLET, + }, + parentReportActions: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID ?? 0}`, + canEvict: false, + }, +})( memo(ReportActionItem, (prevProps, nextProps) => { - const prevParentReportAction = prevProps.parentReportActions[prevProps.report.parentReportActionID]; - const nextParentReportAction = nextProps.parentReportActions[nextProps.report.parentReportActionID]; + const prevParentReportAction = prevProps.parentReportActions?.[prevProps.report.parentReportActionID ?? '']; + const nextParentReportAction = nextProps.parentReportActions?.[nextProps.report.parentReportActionID ?? '']; return ( prevProps.displayAsGroup === nextProps.displayAsGroup && - prevProps.draftMessage === nextProps.draftMessage && prevProps.isMostRecentIOUReportAction === nextProps.isMostRecentIOUReportAction && prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && - _.isEqual(prevProps.emojiReactions, nextProps.emojiReactions) && - _.isEqual(prevProps.action, nextProps.action) && - _.isEqual(prevProps.iouReport, nextProps.iouReport) && - _.isEqual(prevProps.report.pendingFields, nextProps.report.pendingFields) && - _.isEqual(prevProps.report.isDeletedParentAction, nextProps.report.isDeletedParentAction) && - _.isEqual(prevProps.report.errorFields, nextProps.report.errorFields) && - lodashGet(prevProps.report, 'statusNum') === lodashGet(nextProps.report, 'statusNum') && - lodashGet(prevProps.report, 'stateNum') === lodashGet(nextProps.report, 'stateNum') && - lodashGet(prevProps.report, 'parentReportID') === lodashGet(nextProps.report, 'parentReportID') && - lodashGet(prevProps.report, 'parentReportActionID') === lodashGet(nextProps.report, 'parentReportActionID') && - prevProps.translate === nextProps.translate && + lodashIsEqual(prevProps.emojiReactions, nextProps.emojiReactions) && + lodashIsEqual(prevProps.action, nextProps.action) && + lodashIsEqual(prevProps.iouReport, nextProps.iouReport) && + lodashIsEqual(prevProps.report.pendingFields, nextProps.report.pendingFields) && + lodashIsEqual(prevProps.report.isDeletedParentAction, nextProps.report.isDeletedParentAction) && + lodashIsEqual(prevProps.report.errorFields, nextProps.report.errorFields) && + prevProps.report?.statusNum === nextProps.report?.statusNum && + prevProps.report?.stateNum === nextProps.report?.stateNum && + prevProps.report?.parentReportID === nextProps.report?.parentReportID && + prevProps.report?.parentReportActionID === nextProps.report?.parentReportActionID && // TaskReport's created actions render the TaskView, which updates depending on certain fields in the TaskReport ReportUtils.isTaskReport(prevProps.report) === ReportUtils.isTaskReport(nextProps.report) && prevProps.action.actionName === nextProps.action.actionName && @@ -906,13 +910,13 @@ export default compose( ReportUtils.isCompletedTaskReport(prevProps.report) === ReportUtils.isCompletedTaskReport(nextProps.report) && prevProps.report.managerID === nextProps.report.managerID && prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine && - lodashGet(prevProps.report, 'total', 0) === lodashGet(nextProps.report, 'total', 0) && - lodashGet(prevProps.report, 'nonReimbursableTotal', 0) === lodashGet(nextProps.report, 'nonReimbursableTotal', 0) && + prevProps.report?.total === nextProps.report?.total && + prevProps.report?.nonReimbursableTotal === nextProps.report?.nonReimbursableTotal && prevProps.linkedReportActionID === nextProps.linkedReportActionID && - _.isEqual(prevProps.policyReportFields, nextProps.policyReportFields) && - _.isEqual(prevProps.report.reportFields, nextProps.report.reportFields) && - _.isEqual(prevProps.policy, nextProps.policy) && - _.isEqual(prevParentReportAction, nextParentReportAction) + lodashIsEqual(prevProps.policyReportFields, nextProps.policyReportFields) && + lodashIsEqual(prevProps.report.reportFields, nextProps.report.reportFields) && + lodashIsEqual(prevProps.policy, nextProps.policy) && + lodashIsEqual(prevParentReportAction, nextParentReportAction) ); }), ); diff --git a/src/pages/home/report/ReportActionItemBasicMessage.tsx b/src/pages/home/report/ReportActionItemBasicMessage.tsx index 35141a42b726..a28f2af24448 100644 --- a/src/pages/home/report/ReportActionItemBasicMessage.tsx +++ b/src/pages/home/report/ReportActionItemBasicMessage.tsx @@ -5,7 +5,7 @@ import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -type ReportActionItemBasicMessageProps = ChildrenProps & { +type ReportActionItemBasicMessageProps = Partial & { message: string; }; diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index 95578c10e816..4fe52f6adf41 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -35,7 +35,7 @@ type ReportActionItemCreatedProps = ReportActionItemCreatedOnyxProps & { /** The id of the policy */ // eslint-disable-next-line react/no-unused-prop-types - policyID: string; + policyID: string | undefined; }; function ReportActionItemCreated(props: ReportActionItemCreatedProps) { const styles = useThemeStyles(); diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 2c9a4cbd21e8..fbf2da69aa31 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -5,6 +5,7 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; import Composer from '@components/Composer'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; @@ -58,7 +59,7 @@ type ReportActionItemMessageEditProps = { shouldDisableEmojiPicker?: boolean; /** Stores user's preferred skin tone */ - preferredSkinTone?: number; + preferredSkinTone?: OnyxEntry; }; // native ids @@ -69,7 +70,7 @@ const isMobileSafari = Browser.isMobileSafari(); function ReportActionItemMessageEdit( {action, draftMessage, reportID, index, shouldDisableEmojiPicker = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE}: ReportActionItemMessageEditProps, - forwardedRef: ForwardedRef, + forwardedRef: ForwardedRef<(TextInput & HTMLTextAreaElement) | undefined>, ) { const theme = useTheme(); const styles = useThemeStyles(); diff --git a/src/pages/home/report/ReportActionItemParentAction.tsx b/src/pages/home/report/ReportActionItemParentAction.tsx index af1c4e85104e..4a041fc495c0 100644 --- a/src/pages/home/report/ReportActionItemParentAction.tsx +++ b/src/pages/home/report/ReportActionItemParentAction.tsx @@ -83,7 +83,6 @@ function ReportActionItemParentAction({report, index = 0, shouldHideThreadDivide onClose={() => Report.navigateToConciergeChatAndDeleteReport(ancestor.report.reportID)} > Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID))} report={ancestor.report} action={ancestor.reportAction} diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 741422cc7e82..696cd7a7d850 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -1,6 +1,7 @@ import React, {useCallback, useMemo} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import Avatar from '@components/Avatar'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -29,7 +30,7 @@ import ReportActionItemFragment from './ReportActionItemFragment'; type ReportActionItemSingleProps = Partial & { /** All the data of the action */ - action: ReportAction; + action: OnyxEntry; /** Styles for the outermost View */ wrapperStyle?: StyleProp; @@ -38,7 +39,7 @@ type ReportActionItemSingleProps = Partial & { report: Report; /** IOU Report for this action, if any */ - iouReport?: Report; + iouReport?: OnyxEntry; /** Show header for action */ showHeader?: boolean; @@ -77,12 +78,12 @@ function ReportActionItemSingle({ const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT; - const actorAccountID = action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport ? iouReport.managerID : action.actorAccountID; + const actorAccountID = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport ? iouReport.managerID : action?.actorAccountID; let displayName = ReportUtils.getDisplayNameForParticipant(actorAccountID); const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails[actorAccountID ?? -1] ?? {}; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let actorHint = (login || (displayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); - const displayAllActors = useMemo(() => action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport, [action.actionName, iouReport]); + const displayAllActors = useMemo(() => action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport, [action?.actionName, iouReport]); const isWorkspaceActor = ReportUtils.isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors); let avatarSource = UserUtils.getAvatar(avatar ?? '', actorAccountID); @@ -90,7 +91,7 @@ function ReportActionItemSingle({ displayName = ReportUtils.getPolicyName(report); actorHint = displayName; avatarSource = ReportUtils.getWorkspaceAvatar(report); - } else if (action.delegateAccountID && personalDetails[action.delegateAccountID]) { + } else if (action?.delegateAccountID && personalDetails[action?.delegateAccountID]) { // We replace the actor's email, name, and avatar with the Copilot manually for now. And only if we have their // details. This will be improved upon when the Copilot feature is implemented. const delegateDetails = personalDetails[action.delegateAccountID]; @@ -141,7 +142,7 @@ function ReportActionItemSingle({ text: displayName, }, ] - : action.person; + : action?.person; const reportID = report?.reportID; const iouReportID = iouReport?.reportID; @@ -155,14 +156,14 @@ function ReportActionItemSingle({ Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(iouReportID)); return; } - showUserDetails(action.delegateAccountID ? String(action.delegateAccountID) : String(actorAccountID)); + showUserDetails(action?.delegateAccountID ? String(action.delegateAccountID) : String(actorAccountID)); } - }, [isWorkspaceActor, reportID, actorAccountID, action.delegateAccountID, iouReportID, displayAllActors]); + }, [isWorkspaceActor, reportID, actorAccountID, action?.delegateAccountID, iouReportID, displayAllActors]); const shouldDisableDetailPage = useMemo( () => CONST.RESTRICTED_ACCOUNT_IDS.includes(actorAccountID ?? 0) || - (!isWorkspaceActor && ReportUtils.isOptimisticPersonalDetail(action.delegateAccountID ? Number(action.delegateAccountID) : actorAccountID ?? -1)), + (!isWorkspaceActor && ReportUtils.isOptimisticPersonalDetail(action?.delegateAccountID ? Number(action.delegateAccountID) : actorAccountID ?? -1)), [action, isWorkspaceActor, actorAccountID], ); @@ -189,7 +190,7 @@ function ReportActionItemSingle({ return ( @@ -237,13 +238,13 @@ function ReportActionItemSingle({ {personArray?.map((fragment, index) => ( ))} @@ -255,7 +256,7 @@ function ReportActionItemSingle({ >{`${status?.emojiCode}`} )} - + ) : null} {children} diff --git a/src/pages/home/report/ReportActionItemThread.tsx b/src/pages/home/report/ReportActionItemThread.tsx index f7c7e5fcf91d..c0dbe2a3825d 100644 --- a/src/pages/home/report/ReportActionItemThread.tsx +++ b/src/pages/home/report/ReportActionItemThread.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {GestureResponderEvent} from 'react-native'; import {View} from 'react-native'; import MultipleAvatars from '@components/MultipleAvatars'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; @@ -26,7 +27,7 @@ type ReportActionItemThreadProps = { isHovered: boolean; /** The function that should be called when the thread is LongPressed or right-clicked */ - onSecondaryInteraction: () => void; + onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) => void; }; function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, childReportID, isHovered, onSecondaryInteraction}: ReportActionItemThreadProps) { diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx index b9f49049d51a..34ceb1af3dca 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx +++ b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx @@ -74,7 +74,7 @@ function WalletPage({bankAccountList = {}, cardList = {}, fundList = {}, isLoadi }); const addPaymentMethodAnchorRef = useRef(null); - const paymentMethodButtonRef = useRef(null); + const paymentMethodButtonRef = useRef(null); const [anchorPosition, setAnchorPosition] = useState({ anchorPositionHorizontal: 0, anchorPositionVertical: 0, @@ -163,7 +163,7 @@ function WalletPage({bankAccountList = {}, cardList = {}, fundList = {}, isLoadi setShouldShowDefaultDeleteMenu(false); return; } - paymentMethodButtonRef.current = nativeEvent?.currentTarget as HTMLElement; + paymentMethodButtonRef.current = nativeEvent?.currentTarget as HTMLDivElement; // The delete/default menu if (accountType) { diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 06c2d2e6abce..4b976849b534 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -258,7 +258,9 @@ type OriginalMessageModifiedExpense = { type OriginalMessageReimbursementQueued = { actionName: typeof CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED; - originalMessage: unknown; + originalMessage: { + paymentType: DeepValueOf; + }; }; type OriginalMessageReimbursementDequeued = { diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 321a7a0903d4..02b1dbffeb18 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -27,6 +27,7 @@ import type MapboxAccessToken from './MapboxAccessToken'; import type Modal from './Modal'; import type Network from './Network'; import type {OnyxUpdateEvent, OnyxUpdatesFromServer} from './OnyxUpdatesFromServer'; +import type {DecisionName, OriginalMessageIOU} from './OriginalMessage'; import type PersonalBankAccount from './PersonalBankAccount'; import type {PersonalDetailsList} from './PersonalDetails'; import type PersonalDetails from './PersonalDetails'; @@ -45,7 +46,7 @@ import type RecentlyUsedTags from './RecentlyUsedTags'; import type RecentWaypoint from './RecentWaypoint'; import type ReimbursementAccount from './ReimbursementAccount'; import type Report from './Report'; -import type {ReportActions} from './ReportAction'; +import type {ReportActionBase, ReportActions} from './ReportAction'; import type ReportAction from './ReportAction'; import type ReportActionReactions from './ReportActionReactions'; import type ReportActionsDraft from './ReportActionsDraft'; @@ -152,6 +153,9 @@ export type { PolicyReportField, PolicyReportFields, RecentlyUsedReportFields, + DecisionName, + OriginalMessageIOU, + ReportActionBase, LastPaymentMethod, LastSelectedDistanceRates, InvitedEmailsToAccountIDs, diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index f28e2f2c3f14..a5f0c804184c 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -285,8 +285,7 @@ describe('ReportActionsUtils', () => { reportActionID: '1661970171066216', actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED, originalMessage: { - html: 'Hello world', - whisperedTo: [], + paymentType: 'ACH', }, message: [{html: 'Waiting for the bank account', type: 'Action type', text: 'Action text'}], }, @@ -389,8 +388,7 @@ describe('ReportActionsUtils', () => { reportActionID: '1661970171066216', actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED, originalMessage: { - html: 'Hello world', - whisperedTo: [], + paymentType: 'ACH', }, message: [{html: 'Waiting for the bank account', type: 'Action type', text: 'Action text'}], },