diff --git a/package-lock.json b/package-lock.json index 1e8aeaf24ffe..9f8caf136d98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -223,7 +223,7 @@ "babel-jest": "29.7.0", "babel-loader": "^10.0.0", "babel-plugin-module-resolver": "^5.0.0", - "babel-plugin-react-compiler": "1.0.0", + "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260422", "babel-plugin-react-native-web": "^0.18.7", "babel-plugin-transform-remove-console": "^6.9.4", "clean-webpack-plugin": "^4.0.0", @@ -18638,9 +18638,9 @@ } }, "node_modules/babel-plugin-react-compiler": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", - "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "version": "0.0.0-experimental-a1856f3-20260422", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-0.0.0-experimental-a1856f3-20260422.tgz", + "integrity": "sha512-rYwQFX52TaZk2lJZNLp2V9jun1sSJZEBAW5S4Rjt8mnOjEtz5Cab/7yjG4ZLBWuofgWiqSb6laPVkhBpYd0n4w==", "license": "MIT", "dependencies": { "@babel/types": "^7.26.0" diff --git a/package.json b/package.json index 9f3fc61b869e..7ab0b97ade7b 100644 --- a/package.json +++ b/package.json @@ -286,7 +286,7 @@ "babel-jest": "29.7.0", "babel-loader": "^10.0.0", "babel-plugin-module-resolver": "^5.0.0", - "babel-plugin-react-compiler": "1.0.0", + "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260422", "babel-plugin-react-native-web": "^0.18.7", "babel-plugin-transform-remove-console": "^6.9.4", "clean-webpack-plugin": "^4.0.0", @@ -360,6 +360,7 @@ "webpack-merge": "^5.8.0" }, "overrides": { + "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260422", "braces": "3.0.3", "yargs": "17.7.2", "yargs-parser": "21.1.1", diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx index c4b6239145ca..0685bebf0715 100644 --- a/src/components/AvatarWithImagePicker.tsx +++ b/src/components/AvatarWithImagePicker.tsx @@ -1,5 +1,5 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -141,7 +141,7 @@ function AvatarWithImagePicker({ /** * Validates an image and opens avatar crop modal if valid */ - const showAvatarCropModal = useCallback((image: FileObject) => { + const showAvatarCropModal = (image: FileObject) => { validateAvatarImage(image) .then((validationResult) => { if (!validationResult.isValid) { @@ -161,7 +161,7 @@ function AvatarWithImagePicker({ .catch(() => { setError('attachmentPicker.errorWhileSelectingCorruptedAttachment', {}); }); - }, []); + }; const hideAvatarCropModal = () => { setIsAvatarCropModalOpen(false); @@ -201,23 +201,20 @@ function AvatarWithImagePicker({ return menuItems; }; - const onPressAvatar = useCallback( - (openPicker: OpenPicker) => { - anchorRef.current?.blur(); - if (disabled && enablePreview && onViewPhotoPress) { - onViewPhotoPress(); - return; - } - if (isUsingDefaultAvatar) { - openPicker({ - onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}), - }); - return; - } - setIsMenuVisible((prev) => !prev); - }, - [disabled, enablePreview, isUsingDefaultAvatar, onViewPhotoPress, showAvatarCropModal], - ); + const onPressAvatar = (openPicker: OpenPicker) => { + anchorRef.current?.blur(); + if (disabled && enablePreview && onViewPhotoPress) { + onViewPhotoPress(); + return; + } + if (isUsingDefaultAvatar) { + openPicker({ + onPicked: (data) => showAvatarCropModal(data.at(0) ?? {}), + }); + return; + } + setIsMenuVisible((prev) => !prev); + }; useLayoutEffect(() => { if (!anchorRef.current || !isMenuVisible) { diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx index 4ec12eb75787..4d1d574263c5 100644 --- a/src/components/ConfirmedRoute.tsx +++ b/src/components/ConfirmedRoute.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; import type {ReactNode} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -12,7 +12,6 @@ import {init as initMapboxToken, stop as stopMapboxToken} from '@userActions/Map import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; -import type {WaypointCollection} from '@src/types/onyx/Transaction'; import type IconAsset from '@src/types/utils/IconAsset'; import DistanceMapView from './DistanceMapView'; import ImageSVG from './ImageSVG'; @@ -49,51 +48,38 @@ function ConfirmedRoute({transaction, isSmallerIcon, shouldHaveBorderRadius = tr const [mapboxAccessToken] = useOnyx(ONYXKEYS.MAPBOX_ACCESS_TOKEN); - const getMarkerComponent = useCallback( - (icon: IconAsset): ReactNode => ( - - ), - [theme], - ); - - const getWaypointMarkers = useCallback( - (waypointsData: WaypointCollection): WayPoint[] => { - const numberOfWaypoints = Object.keys(waypointsData).length; - const lastWaypointIndex = numberOfWaypoints - 1; - - return Object.entries(waypointsData) - .map(([key, waypoint]) => { - if (!waypoint?.lat || !waypoint?.lng) { - return; - } - - const index = getWaypointIndex(key); - let MarkerComponent: IconAsset; - if (index === 0) { - MarkerComponent = expensifyIcons.DotIndicatorUnfilled; - } else if (index === lastWaypointIndex) { - MarkerComponent = expensifyIcons.Location; - } else { - MarkerComponent = expensifyIcons.DotIndicator; - } - - return { - id: `${waypoint.lng},${waypoint.lat},${index}`, - coordinate: [waypoint.lng, waypoint.lat] as const, - markerComponent: (): ReactNode => getMarkerComponent(MarkerComponent), - }; - }) - .filter((waypoint): waypoint is WayPoint => !!waypoint); - }, - [getMarkerComponent, expensifyIcons.DotIndicator, expensifyIcons.DotIndicatorUnfilled, expensifyIcons.Location], + const getMarkerComponent = (icon: IconAsset): ReactNode => ( + ); - const waypointMarkers = getWaypointMarkers(waypoints); + const lastWaypointIndex = Object.keys(waypoints).length - 1; + const waypointMarkers: WayPoint[] = []; + for (const [key, waypoint] of Object.entries(waypoints)) { + if (!waypoint?.lat || !waypoint?.lng) { + continue; + } + + const index = getWaypointIndex(key); + let MarkerComponent: IconAsset; + if (index === 0) { + MarkerComponent = expensifyIcons.DotIndicatorUnfilled; + } else if (index === lastWaypointIndex) { + MarkerComponent = expensifyIcons.Location; + } else { + MarkerComponent = expensifyIcons.DotIndicator; + } + + waypointMarkers.push({ + id: `${waypoint.lng},${waypoint.lat},${index}`, + coordinate: [waypoint.lng, waypoint.lat] as const, + markerComponent: (): ReactNode => getMarkerComponent(MarkerComponent), + }); + } useEffect(() => { initMapboxToken(); diff --git a/src/components/DistanceRequest/DistanceRequestFooter.tsx b/src/components/DistanceRequest/DistanceRequestFooter.tsx index 0e12867877a0..5f57737a8026 100644 --- a/src/components/DistanceRequest/DistanceRequestFooter.tsx +++ b/src/components/DistanceRequest/DistanceRequestFooter.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo} from 'react'; +import React from 'react'; import type {ReactNode} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -60,45 +60,37 @@ function DistanceRequestFooter({waypoints, transaction, navigateToWaypointEditPa const mileageRate = isCustomUnitRateIDForP2P(transaction) ? DistanceRequestUtils.getRateForP2P(policyCurrency, transaction) : defaultMileageRate; const {unit} = mileageRate ?? {}; - const getMarkerComponent = useCallback( - (icon: IconAsset): ReactNode => ( - - ), - [theme], + const getMarkerComponent = (icon: IconAsset): ReactNode => ( + ); - const waypointMarkers = useMemo( - () => - Object.entries(waypoints ?? {}) - .map(([key, waypoint]) => { - if (!waypoint?.lat || !waypoint?.lng) { - return; - } + const waypointMarkers: WayPoint[] = []; + for (const [key, waypoint] of Object.entries(waypoints ?? {})) { + if (!waypoint?.lat || !waypoint?.lng) { + continue; + } - const index = getWaypointIndex(key); - let MarkerComponent: IconAsset; - if (index === 0) { - MarkerComponent = expensifyIcons.DotIndicatorUnfilled; - } else if (index === lastWaypointIndex) { - MarkerComponent = expensifyIcons.Location; - } else { - MarkerComponent = expensifyIcons.DotIndicator; - } + const index = getWaypointIndex(key); + let MarkerComponent: IconAsset; + if (index === 0) { + MarkerComponent = expensifyIcons.DotIndicatorUnfilled; + } else if (index === lastWaypointIndex) { + MarkerComponent = expensifyIcons.Location; + } else { + MarkerComponent = expensifyIcons.DotIndicator; + } - return { - id: `${waypoint.lng},${waypoint.lat},${index}`, - coordinate: [waypoint.lng, waypoint.lat] as const, - markerComponent: (): ReactNode => getMarkerComponent(MarkerComponent), - }; - }) - .filter((waypoint): waypoint is WayPoint => !!waypoint), - [waypoints, lastWaypointIndex, getMarkerComponent, expensifyIcons.DotIndicator, expensifyIcons.DotIndicatorUnfilled, expensifyIcons.Location], - ); + waypointMarkers.push({ + id: `${waypoint.lng},${waypoint.lat},${index}`, + coordinate: [waypoint.lng, waypoint.lat] as const, + markerComponent: (): ReactNode => getMarkerComponent(MarkerComponent), + }); + } return ( <> diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx index 6eb4672cf35f..3c3fea6df87e 100644 --- a/src/components/FeatureTrainingModal.tsx +++ b/src/components/FeatureTrainingModal.tsx @@ -1,6 +1,6 @@ import type {ImageContentFit} from 'expo-image'; import type {SourceLoadEventPayload} from 'expo-video'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {Image, InteractionManager, View} from 'react-native'; // eslint-disable-next-line no-restricted-imports @@ -267,7 +267,7 @@ function FeatureTrainingModal({ setIllustrationAspectRatio(track.size.width / track.size.height); }; - const renderIllustration = useCallback(() => { + const renderIllustration = () => { const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO; return ( @@ -333,34 +333,11 @@ function FeatureTrainingModal({ )} ); - }, [ - illustrationAspectRatio, - styles.w100, - styles.featureTrainingModalImage, - styles.onboardingVideoPlayer, - styles.flex1, - styles.alignItemsCenter, - styles.justifyContentCenter, - styles.h100, - illustrationInnerContainerStyle, - videoURL, - image, - shouldRenderSVG, - contentFitImage, - imageWidth, - imageHeight, - videoStatus, - animationStyle, - animation, - shouldUseNarrowLayout, - isInLandscapeMode, - isReduceMotionEnabled, - illustrations.Hands, - ]); - - const toggleWillShowAgain = useCallback(() => setWillShowAgain((prevWillShowAgain) => !prevWillShowAgain), []); - - const closeModal = useCallback(() => { + }; + + const toggleWillShowAgain = () => setWillShowAgain((prevWillShowAgain) => !prevWillShowAgain); + + const closeModal = () => { Log.hmmm(`[FeatureTrainingModal] closeModal called - willShowAgain: ${willShowAgain}, shouldGoBack: ${shouldGoBack}, hasOnClose: ${!!onClose}`); if (!willShowAgain) { @@ -387,9 +364,9 @@ function FeatureTrainingModal({ Log.hmmm('[FeatureTrainingModal] No onClose callback provided'); } }); - }, [onClose, shouldGoBack, willShowAgain]); + }; - const closeAndConfirmModal = useCallback(() => { + const closeAndConfirmModal = () => { Log.hmmm(`[FeatureTrainingModal] Button pressed - shouldCloseOnConfirm: ${shouldCloseOnConfirm}, hasOnConfirm: ${!!onConfirm}, willShowAgain: ${willShowAgain}`); if (shouldCloseOnConfirm) { @@ -403,7 +380,7 @@ function FeatureTrainingModal({ } else { Log.hmmm('[FeatureTrainingModal] No onConfirm callback provided'); } - }, [shouldCloseOnConfirm, onConfirm, closeModal, willShowAgain]); + }; // Scrolls modal to the bottom when keyboard appears so the action buttons are visible. useEffect(() => { @@ -415,10 +392,7 @@ function FeatureTrainingModal({ const Wrapper = shouldUseScrollView ? ScrollView : View; - const wrapperStyles = useMemo( - () => (shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {}), - [shouldUseScrollView, StyleUtils, insets, isKeyboardActive], - ); + const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {}; return ( void; }; -type PopoverReactionListAnchors = Record; - type FormattedReaction = { /** The emoji codes to display in the bubble */ emojiCodes: string[]; @@ -61,24 +54,17 @@ type FormattedReaction = { /** Callback to fire on press */ onPress: () => void; - /** Callback to fire on reaction list open */ - onReactionListOpen: (event: ReactionListEvent) => void; - /** The name of the emoji */ reactionEmojiName: string; - /** The type of action that's pending */ + /** The type of action that's pending */ pendingAction?: PendingAction; - - setIsEmojiPickerActive?: (state: boolean) => void; }; function ReportActionItemEmojiReactions({reportAction, reportID, shouldBlockReactions = false, setIsEmojiPickerActive}: ReportActionItemEmojiReactionsProps) { const styles = useThemeStyles(); const {preferredLocale} = useLocalize(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const reactionListRef = useContext(ReactionListContext); - const popoverReactionListAnchors = useRef({}); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); const reportActionID = reportAction.reportActionID; @@ -116,10 +102,6 @@ function ReportActionItemEmojiReactions({reportAction, reportID, shouldBlockReac toggleReaction(emoji, preferredSkinTone, true); }; - const onReactionListOpen = (event: ReactionListEvent) => { - reactionListRef?.current?.showReactionList(event, popoverReactionListAnchors.current[emojiName], emojiName, reportActionID); - }; - return { emojiCodes, userAccountIDs, @@ -127,7 +109,6 @@ function ReportActionItemEmojiReactions({reportAction, reportID, shouldBlockReac hasUserReacted, oldestTimestamp, onPress, - onReactionListOpen, reactionEmojiName: emojiName, pendingAction: emojiReaction.pendingAction, }; @@ -146,37 +127,20 @@ function ReportActionItemEmojiReactions({reportAction, reportID, shouldBlockReac } return ( - ( - - )} - renderTooltipContentKey={[...reaction.userAccountIDs.map(String), ...reaction.emojiCodes]} + - - - { - popoverReactionListAnchors.current[reaction.reactionEmojiName] = ref ?? null; - }} - count={reaction.reactionCount} - emojiCodes={reaction.emojiCodes} - onPress={reaction.onPress} - hasUserReacted={reaction.hasUserReacted} - onReactionListOpen={reaction.onReactionListOpen} - shouldBlockReactions={shouldBlockReactions} - /> - - - + emojiCodes={reaction.emojiCodes} + reactionCount={reaction.reactionCount} + hasUserReacted={reaction.hasUserReacted} + userAccountIDs={reaction.userAccountIDs} + reactionEmojiName={reaction.reactionEmojiName} + onPress={reaction.onPress} + reportActionID={reportActionID} + reportActionPendingAction={reportAction.pendingAction} + pendingAction={reaction.pendingAction} + currentUserAccountID={currentUserAccountID} + shouldBlockReactions={shouldBlockReactions} + /> ); })} {!shouldBlockReactions && ( diff --git a/src/components/Reactions/ReportActionReactionBubble.tsx b/src/components/Reactions/ReportActionReactionBubble.tsx new file mode 100644 index 000000000000..f7685579e751 --- /dev/null +++ b/src/components/Reactions/ReportActionReactionBubble.tsx @@ -0,0 +1,98 @@ +import React, {useContext, useRef} from 'react'; +import {View} from 'react-native'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; +import {ReactionListContext} from '@pages/inbox/ReportScreenContext'; +import type {ReactionListAnchor, ReactionListEvent} from '@pages/inbox/ReportScreenContext'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import EmojiReactionBubble from './EmojiReactionBubble'; +import ReactionTooltipContent from './ReactionTooltipContent'; + +type ReportActionReactionBubbleProps = { + /** The emoji codes to display in the bubble */ + emojiCodes: string[]; + + /** Number of reactions for this emoji */ + reactionCount: number; + + /** Whether the current user has reacted with this emoji */ + hasUserReacted: boolean; + + /** IDs of users who reacted with this emoji */ + userAccountIDs: number[]; + + /** Name of the reaction emoji */ + reactionEmojiName: string; + + /** Called when the bubble is pressed (toggles reaction) */ + onPress: () => void; + + /** ID of the report action this reaction belongs to */ + reportActionID: string; + + /** Pending action of the report action (drives offline opacity) */ + reportActionPendingAction: PendingAction | undefined; + + /** Pending action for this individual reaction */ + pendingAction: PendingAction | undefined; + + /** Current user's account ID, used for the tooltip header */ + currentUserAccountID: number; + + /** Disables reactions when the report action has errors */ + shouldBlockReactions: boolean; +}; + +function ReportActionReactionBubble({ + emojiCodes, + reactionCount, + hasUserReacted, + userAccountIDs, + reactionEmojiName, + onPress, + reportActionID, + reportActionPendingAction, + pendingAction, + currentUserAccountID, + shouldBlockReactions, +}: ReportActionReactionBubbleProps) { + const anchorRef = useRef(null); + const {showReactionList} = useContext(ReactionListContext); + + return ( + ( + + )} + renderTooltipContentKey={[...userAccountIDs.map(String), ...emojiCodes]} + > + + + { + anchorRef.current = node ?? null; + }} + count={reactionCount} + emojiCodes={emojiCodes} + onPress={onPress} + hasUserReacted={hasUserReacted} + onReactionListOpen={(event: ReactionListEvent) => showReactionList(event, anchorRef.current, reactionEmojiName, reportActionID)} + shouldBlockReactions={shouldBlockReactions} + /> + + + + ); +} + +ReportActionReactionBubble.displayName = 'ReportActionReactionBubble'; + +export default ReportActionReactionBubble; diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index a5763fc11184..7c711a5d3a29 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -15,7 +15,7 @@ import type {ChartView, GroupedItem, SearchChartProps, SearchGroupBy, SearchQuer type SearchChartViewProps = { /** The current search query JSON */ - queryJSON: SearchQueryJSON; + queryJSON: Readonly; /** The view type (bar, etc.) */ view: ChartView; @@ -51,16 +51,19 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading}: SearchChar const handleItemPress = (filterQuery: string) => { const currentQueryString = buildSearchQueryString(queryJSON); - const newQueryJSON = buildSearchQueryJSON(`${currentQueryString} ${filterQuery}`); + const parsedQueryJSON = buildSearchQueryJSON(`${currentQueryString} ${filterQuery}`); - if (!newQueryJSON) { + if (!parsedQueryJSON) { Log.alert('[SearchChartView] Failed to build search query JSON from filter query'); return; } - newQueryJSON.groupBy = undefined; - newQueryJSON.view = CONST.SEARCH.VIEW.TABLE; - newQueryJSON.sortBy = CONST.SEARCH.TABLE_COLUMNS.DATE; - newQueryJSON.sortOrder = CONST.SEARCH.SORT_ORDER.DESC; + const newQueryJSON: SearchQueryJSON = { + ...parsedQueryJSON, + groupBy: undefined, + view: CONST.SEARCH.VIEW.TABLE, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + }; const newQueryString = buildSearchQueryString(newQueryJSON); Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: newQueryString})); diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 654381149e72..e0904e5441c4 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -1,6 +1,6 @@ import {useNavigation} from '@react-navigation/core'; import type {NavigationState} from '@react-navigation/routers'; -import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useContext, useEffect, useRef, useState} from 'react'; // We need direct access to useOnyx from react-native-onyx to avoid circular dependencies in SearchContext // eslint-disable-next-line no-restricted-imports import {useOnyx} from 'react-native-onyx'; @@ -98,7 +98,7 @@ function SearchContextProvider({children}: SearchContextProps) { const queryParam = useRootNavigationState((state) => selectSearchQueryParam(state ?? navigation.getState())); const rawQueryParam = useRootNavigationState((state) => selectSearchRawQueryParam(state ?? navigation.getState())); const definedQueryParam = usePreviousDefined(queryParam) ?? buildSearchQueryString(); - const currentSearchQueryJSON = useMemo(() => buildSearchQueryJSON(definedQueryParam, rawQueryParam), [definedQueryParam, rawQueryParam]); + const currentSearchQueryJSON = buildSearchQueryJSON(definedQueryParam, rawQueryParam); const areTransactionsEmpty = useRef(true); const [lastSearchType, setLastSearchType] = useState(); @@ -117,38 +117,35 @@ function SearchContextProvider({children}: SearchContextProps) { const {defaultCardFeed} = useCardFeedsForDisplay(); const {accountID} = useCurrentUserPersonalDetails(); const defaultCardFeedID = defaultCardFeed?.id; - const suggestedSearches = useMemo(() => getSuggestedSearches(accountID, defaultCardFeedID), [accountID, defaultCardFeedID]); + const suggestedSearches = getSuggestedSearches(accountID, defaultCardFeedID); - const currentSearchKey = useMemo(() => { - return Object.values(suggestedSearches).find((search) => search.similarSearchHash === currentSimilarSearchHash)?.key; - }, [currentSimilarSearchHash, suggestedSearches]); + const currentSearchKey = Object.values(suggestedSearches).find((search) => search.similarSearchHash === currentSimilarSearchHash)?.key; const shouldUseLiveData = !!currentSearchKey && isTodoSearch(currentRecentSearchHash, suggestedSearches); // If viewing a to-do search, use live data from useTodos, otherwise return the snapshot data // We do this so that we can show the counters for the to-do search results without visiting the specific to-do page, e.g. show `Approve [3]` while viewing the `Submit` to-do search. - const currentSearchResults = useMemo(() => { - if (shouldUseLiveData) { - const liveData = todoSearchResultsData[currentSearchKey as keyof typeof todoSearchResultsData]; - const searchInfo: SearchResultsInfo = { - ...(snapshotSearchResults?.search ?? defaultSearchInfo), - count: liveData.metadata.count, - total: liveData.metadata.total, - currency: liveData.metadata.currency, - }; - const hasResults = Object.keys(liveData.data).length > 0; - // For to-do searches, always return a valid SearchResults object (even with empty data) - // This ensures we show the empty state instead of loading/blocking views - return { - search: {...searchInfo, isLoading: false, hasResults}, - data: liveData.data, - }; - } - - return snapshotSearchResults ?? undefined; - }, [currentSearchKey, shouldUseLiveData, snapshotSearchResults, todoSearchResultsData]); - - const setSelectedTransactions: SearchActionsContextValue['setSelectedTransactions'] = useCallback((transactionIDs, data = []) => { + let currentSearchResults; + if (shouldUseLiveData) { + const liveData = todoSearchResultsData[currentSearchKey as keyof typeof todoSearchResultsData]; + const searchInfo: SearchResultsInfo = { + ...(snapshotSearchResults?.search ?? defaultSearchInfo), + count: liveData.metadata.count, + total: liveData.metadata.total, + currency: liveData.metadata.currency, + }; + const hasResults = Object.keys(liveData.data).length > 0; + // For to-do searches, always return a valid SearchResults object (even with empty data) + // This ensures we show the empty state instead of loading/blocking views + currentSearchResults = { + search: {...searchInfo, isLoading: false, hasResults}, + data: liveData.data, + }; + } else { + currentSearchResults = snapshotSearchResults ?? undefined; + } + + const setSelectedTransactions: SearchActionsContextValue['setSelectedTransactions'] = (transactionIDs, data = []) => { if (transactionIDs instanceof Array) { if (!transactionIDs.length && areTransactionsEmpty.current) { areTransactionsEmpty.current = true; @@ -234,7 +231,7 @@ function SearchContextProvider({children}: SearchContextProps) { selectedTransactions: transactionIDs, shouldTurnOffSelectionMode: false, })); - }, []); + }; const currentSearchHashRef = useRef(currentSearchHash); useEffect(() => { @@ -254,38 +251,33 @@ function SearchContextProvider({children}: SearchContextProps) { }); }; - const clearSelectedTransactions: SearchActionsContextValue['clearSelectedTransactions'] = useCallback( - (searchHashOrClearIDsFlag, shouldTurnOffSelectionMode = false) => { - if (typeof searchHashOrClearIDsFlag === 'boolean') { - setSelectedTransactions([]); - return; - } + const clearSelectedTransactions: SearchActionsContextValue['clearSelectedTransactions'] = (searchHashOrClearIDsFlag, shouldTurnOffSelectionMode = false) => { + if (typeof searchHashOrClearIDsFlag === 'boolean') { + setSelectedTransactions([]); + return; + } - if (searchHashOrClearIDsFlag === currentSearchHashRef.current) { - return; + if (searchHashOrClearIDsFlag === currentSearchHashRef.current) { + return; + } + + setSearchContextData((prevState) => { + if (prevState.selectedReports.length === 0 && isEmptyObject(prevState.selectedTransactions) && !prevState.shouldTurnOffSelectionMode) { + return prevState; } + return { + ...prevState, + shouldTurnOffSelectionMode, + selectedTransactions: {}, + selectedReports: [], + }; + }); - setSearchContextData((prevState) => { - if (prevState.selectedReports.length === 0 && isEmptyObject(prevState.selectedTransactions) && !prevState.shouldTurnOffSelectionMode) { - return prevState; - } - return { - ...prevState, - shouldTurnOffSelectionMode, - selectedTransactions: {}, - selectedReports: [], - }; - }); - - setShouldShowSelectAllMatchingItems(false); - selectAllMatchingItems(false); - }, - // currentSearchHash is read via currentSearchHashRef to keep this callback stable. - // setShouldShowSelectAllMatchingItems and selectAllMatchingItems are stable useState setters. - [setSelectedTransactions], - ); + setShouldShowSelectAllMatchingItems(false); + selectAllMatchingItems(false); + }; - const removeTransaction: SearchActionsContextValue['removeTransaction'] = useCallback((transactionID) => { + const removeTransaction: SearchActionsContextValue['removeTransaction'] = (transactionID) => { if (!transactionID) { return; } @@ -314,63 +306,41 @@ function SearchContextProvider({children}: SearchContextProps) { } return newState; }); - }, []); + }; - const setShouldResetSearchQuery = useCallback((shouldReset: boolean) => { + const setShouldResetSearchQuery = (shouldReset: boolean) => { setSearchContextData((prevState) => ({ ...prevState, shouldResetSearchQuery: shouldReset, })); - }, []); - - const searchStateContextValue: SearchStateContextValue = useMemo( - () => ({ - ...searchContextData, - suggestedSearches, - currentSearchKey, - currentSearchHash, - currentSimilarSearchHash, - currentSearchResults, - shouldUseLiveData, - shouldShowFiltersBarLoading, - lastSearchType, - shouldShowSelectAllMatchingItems, - areAllMatchingItemsSelected, - currentSearchQueryJSON, - }), - [ - searchContextData, - suggestedSearches, - currentSearchKey, - currentSearchHash, - currentSimilarSearchHash, - currentSearchResults, - shouldUseLiveData, - shouldShowFiltersBarLoading, - lastSearchType, - shouldShowSelectAllMatchingItems, - areAllMatchingItemsSelected, - currentSearchQueryJSON, - ], - ); + }; - const searchActionsContextValue: SearchActionsContextValue = useMemo( - () => ({ - removeTransaction, - setSelectedTransactions, - setCurrentSelectedTransactionReportID, - clearSelectedTransactions, - setShouldShowFiltersBarLoading, - setLastSearchType, - setShouldShowSelectAllMatchingItems, - selectAllMatchingItems, - setShouldResetSearchQuery, - }), - // shouldShowFiltersBarLoading, setLastSearchType, setShouldShowSelectAllMatchingItems, - // and selectAllMatchingItems are stable useState setters — excluded from deps intentionally. - // setCurrentSelectedTransactionReportID only uses setSearchContextData (stable setter). - [removeTransaction, setSelectedTransactions, clearSelectedTransactions, setShouldResetSearchQuery], - ); + const searchStateContextValue: SearchStateContextValue = { + ...searchContextData, + suggestedSearches, + currentSearchKey, + currentSearchHash, + currentSimilarSearchHash, + currentSearchResults, + shouldUseLiveData, + shouldShowFiltersBarLoading, + lastSearchType, + shouldShowSelectAllMatchingItems, + areAllMatchingItemsSelected, + currentSearchQueryJSON, + }; + + const searchActionsContextValue: SearchActionsContextValue = { + removeTransaction, + setSelectedTransactions, + setCurrentSelectedTransactionReportID, + clearSelectedTransactions, + setShouldShowFiltersBarLoading, + setLastSearchType, + setShouldShowSelectAllMatchingItems, + selectAllMatchingItems, + setShouldResetSearchQuery, + }; return ( diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 69e4b2dced4f..92bc0c505a31 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -169,7 +169,7 @@ type SearchContextData = { currentSearchHash: number; currentSimilarSearchHash: number; currentSearchKey: SearchKey | undefined; - currentSearchQueryJSON: SearchQueryJSON | undefined; + currentSearchQueryJSON: Readonly | undefined; currentSearchResults: SearchResults | undefined; currentSelectedTransactionReportID: string | undefined; selectedTransactions: SelectedTransactions; @@ -326,7 +326,7 @@ type SearchAutocompleteQueryRange = { }; type SearchParams = { - queryJSON: SearchQueryJSON; + queryJSON: Readonly; searchKey: SearchKey | undefined; offset: number; prevReportsLength?: number; diff --git a/src/components/VideoPlayerContexts/PlaybackContext/usePlaybackContextVideoRefs.ts b/src/components/VideoPlayerContexts/PlaybackContext/usePlaybackContextVideoRefs.ts index 7893e0c62a0d..92be05a99362 100644 --- a/src/components/VideoPlayerContexts/PlaybackContext/usePlaybackContextVideoRefs.ts +++ b/src/components/VideoPlayerContexts/PlaybackContext/usePlaybackContextVideoRefs.ts @@ -1,61 +1,55 @@ -import {useCallback, useMemo, useRef} from 'react'; +import {useRef} from 'react'; import type {PlaybackContextVideoRefs, StopVideo} from './types'; function usePlaybackContextVideoRefs(resetCallback: () => void) { const currentVideoPlayerRef: PlaybackContextVideoRefs['playerRef'] = useRef(null); const currentVideoViewRef: PlaybackContextVideoRefs['viewRef'] = useRef(null); - const playVideo: PlaybackContextVideoRefs['play'] = useCallback(() => { + const playVideo: PlaybackContextVideoRefs['play'] = () => { currentVideoPlayerRef.current?.play(); - }, []); + }; - const pauseVideo: PlaybackContextVideoRefs['pause'] = useCallback(() => { + const pauseVideo: PlaybackContextVideoRefs['pause'] = () => { currentVideoPlayerRef.current?.pause(); - }, []); + }; - const replayVideo: PlaybackContextVideoRefs['replay'] = useCallback(() => { + const replayVideo: PlaybackContextVideoRefs['replay'] = () => { currentVideoPlayerRef.current?.replay(); - }, []); + }; - const stopVideo: StopVideo = useCallback(() => { + const stopVideo: StopVideo = () => { if (!currentVideoPlayerRef.current) { return; } currentVideoPlayerRef.current.pause(); currentVideoPlayerRef.current.currentTime = 0; - }, [currentVideoPlayerRef]); + }; - const checkIfVideoIsPlaying: PlaybackContextVideoRefs['isPlaying'] = useCallback( - (statusCallback) => statusCallback(currentVideoPlayerRef.current?.playing ?? false), - [currentVideoPlayerRef], - ); + const checkIfVideoIsPlaying: PlaybackContextVideoRefs['isPlaying'] = (statusCallback) => statusCallback(currentVideoPlayerRef.current?.playing ?? false); - const resetVideoPlayerData: PlaybackContextVideoRefs['resetPlayerData'] = useCallback(() => { + const resetVideoPlayerData: PlaybackContextVideoRefs['resetPlayerData'] = () => { stopVideo(); currentVideoPlayerRef.current = null; currentVideoViewRef.current = null; resetCallback(); - }, [resetCallback, stopVideo]); + }; const updateCurrentVideoPlayerRefs: PlaybackContextVideoRefs['updateRefs'] = (playerRef, viewRef) => { currentVideoPlayerRef.current = playerRef; currentVideoViewRef.current = viewRef; }; - return useMemo( - (): PlaybackContextVideoRefs => ({ - playerRef: currentVideoPlayerRef, - viewRef: currentVideoViewRef, - play: playVideo, - pause: pauseVideo, - replay: replayVideo, - stop: stopVideo, - isPlaying: checkIfVideoIsPlaying, - resetPlayerData: resetVideoPlayerData, - updateRefs: updateCurrentVideoPlayerRefs, - }), - [checkIfVideoIsPlaying, pauseVideo, playVideo, replayVideo, resetVideoPlayerData, stopVideo], - ); + return { + playerRef: currentVideoPlayerRef, + viewRef: currentVideoViewRef, + play: playVideo, + pause: pauseVideo, + replay: replayVideo, + stop: stopVideo, + isPlaying: checkIfVideoIsPlaying, + resetPlayerData: resetVideoPlayerData, + updateRefs: updateCurrentVideoPlayerRefs, + }; } export default usePlaybackContextVideoRefs; diff --git a/src/hooks/useBasePopoverReactionList/index.ts b/src/hooks/useBasePopoverReactionList/index.ts deleted file mode 100644 index 22fa8413f67d..000000000000 --- a/src/hooks/useBasePopoverReactionList/index.ts +++ /dev/null @@ -1,131 +0,0 @@ -import {useEffect, useRef, useState} from 'react'; -import type {SyntheticEvent} from 'react'; -import {Dimensions} from 'react-native'; -import * as EmojiUtils from '@libs/EmojiUtils'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; -import type {BasePopoverReactionListHookProps, ReactionListAnchor, ShowReactionList} from './types'; - -export default function useBasePopoverReactionList({emojiName, emojiReactions, accountID, reportActionID, preferredLocale}: BasePopoverReactionListHookProps) { - const [isPopoverVisible, setIsPopoverVisible] = useState(false); - const [cursorRelativePosition, setCursorRelativePosition] = useState({horizontal: 0, vertical: 0}); - const [popoverAnchorPosition, setPopoverAnchorPosition] = useState({horizontal: 0, vertical: 0}); - const reactionListRef = useRef(null); - - function getReactionInformation() { - const selectedReaction = emojiReactions?.[emojiName]; - - if (!selectedReaction) { - // If there is no reaction, we return default values - return { - emojiName: '', - reactionCount: 0, - emojiCodes: [], - hasUserReacted: false, - users: [], - isReady: false, - }; - } - - const {emojiCodes, reactionCount, hasUserReacted, userAccountIDs} = EmojiUtils.getEmojiReactionDetails(emojiName, selectedReaction, accountID); - - const users = PersonalDetailsUtils.getPersonalDetailsByIDs({accountIDs: userAccountIDs, currentUserAccountID: accountID, shouldChangeUserDisplayName: true}); - return { - emojiName, - emojiCodes, - reactionCount, - hasUserReacted, - users, - isReady: true, - }; - } - - /** - * Get the BasePopoverReactionList anchor position - * We calculate the anchor coordinates from measureInWindow async method - */ - function getReactionListMeasuredLocation(): Promise<{x: number; y: number}> { - return new Promise((resolve) => { - const reactionListAnchor = reactionListRef.current; - if (reactionListAnchor && 'measureInWindow' in reactionListAnchor) { - reactionListAnchor.measureInWindow((x, y) => resolve({x, y})); - } else { - resolve({x: 0, y: 0}); - } - }); - } - - /** - * Show the ReactionList modal popover. - * - * @param event - Object - A press event. - * @param reactionListAnchor - Element - reactionListAnchor - */ - const showReactionList: ShowReactionList = (event, reactionListAnchor) => { - // We get the cursor coordinates and the reactionListAnchor coordinates to calculate the popover position - const nativeEvent = (event as SyntheticEvent)?.nativeEvent || {}; - reactionListRef.current = reactionListAnchor; - getReactionListMeasuredLocation().then(({x, y}) => { - setCursorRelativePosition({horizontal: nativeEvent.pageX - x, vertical: nativeEvent.pageY - y}); - setPopoverAnchorPosition({ - horizontal: nativeEvent.pageX, - vertical: nativeEvent.pageY, - }); - setIsPopoverVisible(true); - }); - }; - - /** - * Hide the ReactionList modal popover. - */ - function hideReactionList() { - setIsPopoverVisible(false); - } - - useEffect(() => { - const dimensionsEventListener = Dimensions.addEventListener('change', () => { - if (!isPopoverVisible) { - // If the popover is not visible, we don't need to update the component - return; - } - getReactionListMeasuredLocation().then(({x, y}) => { - if (!x || !y) { - return; - } - setPopoverAnchorPosition({ - horizontal: cursorRelativePosition.horizontal + x, - vertical: cursorRelativePosition.vertical + y, - }); - }); - }); - - return () => { - dimensionsEventListener.remove(); - }; - }, [ - isPopoverVisible, - reportActionID, - preferredLocale, - cursorRelativePosition.horizontal, - cursorRelativePosition.vertical, - popoverAnchorPosition.horizontal, - popoverAnchorPosition.vertical, - ]); - - useEffect(() => { - if (!isPopoverVisible) { - // If the popover is not visible, we don't need to update the component - return; - } - - // Hide the list when all reactions are removed - const users = emojiReactions?.[emojiName]?.users; - - if (!users || Object.keys(users).length > 0) { - return; - } - - hideReactionList(); - }, [emojiReactions, emojiName, isPopoverVisible, reportActionID, preferredLocale]); - - return {isPopoverVisible, cursorRelativePosition, popoverAnchorPosition, getReactionInformation, hideReactionList, reactionListRef, showReactionList}; -} diff --git a/src/hooks/useBasePopoverReactionList/types.ts b/src/hooks/useBasePopoverReactionList/types.ts deleted file mode 100644 index 1b944f8cd233..000000000000 --- a/src/hooks/useBasePopoverReactionList/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type {ForwardedRef} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import type {LocaleContextProps} from '@components/LocaleContextProvider'; -import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import type {ReactionListAnchor, ReactionListEvent, ReactionListRef} from '@pages/inbox/ReportScreenContext'; -import type {ReportActionReactions} from '@src/types/onyx'; - -type BasePopoverReactionListProps = { - /** The ID of the report action */ - reportActionID: string; - - /** The emoji name */ - emojiName: string; - - /** Reference to the outer element */ - ref: ForwardedRef>; -}; - -type BasePopoverReactionListHookProps = Omit & { - /** The reactions for the report action */ - emojiReactions: OnyxEntry; - - /** The current user's account ID */ - accountID: WithCurrentUserPersonalDetailsProps['currentUserPersonalDetails']['accountID']; - - preferredLocale: LocaleContextProps['preferredLocale']; -}; - -type ShowReactionList = (event: ReactionListEvent | undefined, reactionListAnchor: ReactionListAnchor) => void; - -type InnerReactionListRef = { - showReactionList: ShowReactionList; - hideReactionList: () => void; - isActiveReportAction: (actionID: number | string) => boolean; -}; - -export type {BasePopoverReactionListHookProps, BasePopoverReactionListProps, ShowReactionList, ReactionListAnchor, InnerReactionListRef}; diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index 53935137a3ca..8cf706060819 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -17,7 +17,7 @@ import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; * - Fires openSearch() to load bank account data * - Re-fires openSearch() when coming back online */ -function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) { +function useSearchPageSetup(queryJSON: Readonly | undefined) { const {isOffline} = useNetwork(); const prevIsOffline = usePrevious(isOffline); const {clearSelectedTransactions} = useSearchActionsContext(); diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index dc5a9c3a183b..ac558c6966b1 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -450,7 +450,7 @@ function getDefaultSearchQueryJSON() { return defaultSearchQueryJSON; } -function wasViewExplicitlySet(queryJSON?: SearchQueryJSON) { +function wasViewExplicitlySet(queryJSON?: SearchQueryJSON | Readonly) { if (!queryJSON?.view) { return false; } @@ -578,17 +578,21 @@ function getRawFilterListFromQuery(rawQuery: SearchQueryString) { } // Cache for buildSearchQueryJSON to avoid re-running the PEG parser for identical queries. +// Cached values are shallow-frozen; callers needing to change fields must spread into a new object first. // This is a pure function called from 64+ sites — many fire during the same render cycle // with identical query strings, each running the full parser from scratch. -const buildSearchQueryJSONCache = new Map(); +const buildSearchQueryJSONCache = new Map | undefined>(); const BUILD_SEARCH_QUERY_JSON_CACHE_MAX_SIZE = 50; const BUILD_SEARCH_QUERY_JSON_CACHE_KEY_SEPARATOR = '\x00'; // Null byte prevents collisions if query/rawQuery contain arbitrary strings -function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQueryString) { - const cacheKey = rawQuery ? `${query}${BUILD_SEARCH_QUERY_JSON_CACHE_KEY_SEPARATOR}${rawQuery}` : query; +function getBuildSearchQueryJSONCacheKey(query: SearchQueryString, rawQuery?: SearchQueryString) { + return rawQuery ? `${query}${BUILD_SEARCH_QUERY_JSON_CACHE_KEY_SEPARATOR}${rawQuery}` : query; +} + +function getCachedSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQueryString): Readonly | undefined { + const cacheKey = getBuildSearchQueryJSONCacheKey(query, rawQuery); if (buildSearchQueryJSONCache.has(cacheKey)) { - const cached = buildSearchQueryJSONCache.get(cacheKey); - return cached ? {...cached} : cached; + return buildSearchQueryJSONCache.get(cacheKey); } try { @@ -628,21 +632,27 @@ function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQuerySt buildSearchQueryJSONCache.delete(firstKey); } } - buildSearchQueryJSONCache.set(cacheKey, result); + const frozen = Object.freeze(result); + buildSearchQueryJSONCache.set(cacheKey, frozen); - return {...result}; + return frozen; } catch (e) { console.error(`Error when parsing SearchQuery: "${query}"`, e); } } +/** Parses {@link query} (and optionally {@link rawQuery}) into JSON. Repeated calls share the same cached object identity for referential stability. */ +function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQueryString): Readonly | undefined { + return getCachedSearchQueryJSON(query, rawQuery); +} + /** * Formats a given `SearchQueryJSON` object into the string version of query. * This format of query is the most basic string format and is used as the query param `q` in search URLs. * * In a way this is the reverse of buildSearchQueryJSON() */ -function buildSearchQueryString(queryJSON?: SearchQueryJSON) { +function buildSearchQueryString(queryJSON?: SearchQueryJSON | Readonly) { const queryParts: string[] = []; const defaultQueryJSON = buildSearchQueryJSON(''); const isViewExplicitlySet = wasViewExplicitlySet(queryJSON); @@ -1784,11 +1794,11 @@ function buildCannedSearchQuery({ return buildSearchQueryString(normalizedQueryJSON); } -function isDefaultExpensesQuery(queryJSON: SearchQueryJSON) { +function isDefaultExpensesQuery(queryJSON: SearchQueryJSON | Readonly) { return queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE && !queryJSON.status && !queryJSON.filters && !queryJSON.groupBy && !queryJSON.policyID; } -function isDefaultExpenseReportsQuery(queryJSON: SearchQueryJSON) { +function isDefaultExpenseReportsQuery(queryJSON: SearchQueryJSON | Readonly) { return queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && !queryJSON.status && !queryJSON.filters && !queryJSON.groupBy && !queryJSON.policyID; } @@ -1808,8 +1818,11 @@ const sortOptionsWithEmptyValue = (a: string, b: string, localeCompare: LocaleCo /** * Given a search query, this function will standardize the query by replacing display values with their corresponding IDs. */ -function traverseAndUpdatedQuery(queryJSON: SearchQueryJSON, computeNodeValue: (left: ValueOf, right: string | string[]) => string | string[]) { - const standardQuery = cloneDeep(queryJSON); +function traverseAndUpdatedQuery( + queryJSON: SearchQueryJSON | Readonly, + computeNodeValue: (left: ValueOf, right: string | string[]) => string | string[], +) { + const standardQuery = cloneDeep(queryJSON) as SearchQueryJSON; const filters = standardQuery.filters; const traverse = (node: ASTNode) => { if (!node.operator) { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index a954196f0df9..0ed173b6ce97 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2803,7 +2803,7 @@ function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: SearchFi return buildSearchQueryJSON(buildSearchQueryString(newQueryJSON)); } -function getActiveGroupSearchHashes(data: OnyxTypes.SearchResults['data'] | undefined, queryJSON: SearchQueryJSON | undefined): number[] { +function getActiveGroupSearchHashes(data: OnyxTypes.SearchResults['data'] | undefined, queryJSON: Readonly | undefined): number[] { if (!data || !queryJSON?.groupBy) { return []; } @@ -4395,7 +4395,7 @@ function shouldShowEmptyState(isDataLoaded: boolean, dataLength: number, type: S return !isDataLoaded || dataLength === 0 || !type || !Object.values(CONST.SEARCH.DATA_TYPES).includes(type); } -function isSearchDataLoaded(searchResults: SearchResults | undefined, queryJSON: SearchQueryJSON | undefined) { +function isSearchDataLoaded(searchResults: SearchResults | undefined, queryJSON: Readonly | undefined) { const {status} = queryJSON ?? {}; const sortedSearchResultStatus = !Array.isArray(searchResults?.search?.status) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 390ded37d3e8..3fb1e3a4942e 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -2530,7 +2530,7 @@ const expenseReportStatusFilterMapping: Record, iouReport: OnyxEntry, isInvoice: boolean | undefined, transaction?: OnyxEntry, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 62f746d0c9d7..b10a6c9b5699 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -295,7 +295,7 @@ function getPayActionCallback( function getOnyxLoadingData( hash: number, - queryJSON?: SearchQueryJSON, + queryJSON?: Readonly, offset?: number, isOffline?: boolean, isSearchAPI = false, @@ -354,7 +354,7 @@ function getOnyxLoadingData( return {optimisticData, finallyData, failureData}; } -function saveSearch({queryJSON, newName}: {queryJSON: SearchQueryJSON; newName?: string}) { +function saveSearch({queryJSON, newName}: {queryJSON: Readonly; newName?: string}) { const saveSearchName = newName ?? queryJSON?.inputQuery ?? ''; const jsonQuery = JSON.stringify(queryJSON); @@ -521,7 +521,7 @@ function search({ shouldUpdateLastSearchParams = true, skipWaitForWrites = false, }: { - queryJSON: SearchQueryJSON; + queryJSON: Readonly; searchKey: SearchKey | undefined; offset?: number; shouldCalculateTotals?: boolean; diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/JobTitle.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/JobTitle.tsx index 4943fef205d1..3bd150ac7293 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/JobTitle.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/JobTitle.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import SingleFieldStep from '@components/SubStepForms/SingleFieldStep'; import useLocalize from '@hooks/useLocalize'; @@ -18,18 +18,14 @@ function JobTitle({onNext, onMove, isEditing}: JobTitleProps) { const {translate} = useLocalize(); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const inputID = SIGNER_JOB_TITLE; - const defaultValue = String(reimbursementAccountDraft?.[inputID] ?? ''); - - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - return getFieldRequiredErrors(values, [inputID], translate); - }, - [inputID, translate], - ); + const defaultValue = String(reimbursementAccountDraft?.[SIGNER_JOB_TITLE] ?? ''); + + const validate = (values: FormOnyxValues): FormInputErrors => { + return getFieldRequiredErrors(values, [SIGNER_JOB_TITLE], translate); + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ - fieldIds: [inputID], + fieldIds: [SIGNER_JOB_TITLE], onNext, shouldSaveDraft: isEditing, }); @@ -43,7 +39,7 @@ function JobTitle({onNext, onMove, isEditing}: JobTitleProps) { formTitle={translate('signerInfoStep.whatsYourJobTitle')} validate={validate} onSubmit={handleSubmit} - inputId={inputID} + inputId={SIGNER_JOB_TITLE} inputLabel={translate('signerInfoStep.jobTitle')} inputMode={CONST.INPUT_MODE.TEXT} defaultValue={defaultValue} diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Name.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Name.tsx index 6b35a67c4c78..6cc9c8eaa730 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Name.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/Name.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import SingleFieldStep from '@components/SubStepForms/SingleFieldStep'; import useLocalize from '@hooks/useLocalize'; @@ -18,24 +18,20 @@ function Name({onNext, onMove, isEditing}: NameProps) { const {translate} = useLocalize(); const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); - const inputID = SIGNER_FULL_NAME; - const defaultValue = String(reimbursementAccountDraft?.[inputID] ?? ''); + const defaultValue = String(reimbursementAccountDraft?.[SIGNER_FULL_NAME] ?? ''); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, [inputID], translate); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, [SIGNER_FULL_NAME], translate); - if (values[inputID] && !isValidLegalName(String(values[inputID]))) { - errors[inputID] = translate('bankAccount.error.fullName'); - } + if (values[SIGNER_FULL_NAME] && !isValidLegalName(String(values[SIGNER_FULL_NAME]))) { + errors[SIGNER_FULL_NAME] = translate('bankAccount.error.fullName'); + } - return errors; - }, - [inputID, translate], - ); + return errors; + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ - fieldIds: [inputID], + fieldIds: [SIGNER_FULL_NAME], onNext, shouldSaveDraft: isEditing, }); @@ -49,7 +45,7 @@ function Name({onNext, onMove, isEditing}: NameProps) { formTitle={translate('signerInfoStep.whatsYourName')} validate={validate} onSubmit={handleSubmit} - inputId={inputID} + inputId={SIGNER_FULL_NAME} inputLabel={translate('signerInfoStep.fullName')} inputMode={CONST.INPUT_MODE.TEXT} defaultValue={defaultValue} diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx index b8ca7467e107..6e8279bab6f0 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; @@ -43,39 +43,28 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) { const isPDSandFSGDownloaded = reimbursementAccount?.achData?.corpay?.downloadedPDSandFSG ?? reimbursementAccountDraft?.[signerInfoKeys.DOWNLOADED_PDS_AND_FSG] ?? false; const [isPDSandFSGDownloadedTouched, setIsPDSandFSGDownloadedTouched] = useState(false); - const copyOfIDInputID = COPY_OF_ID; - const addressProofInputID = ADDRESS_PROOF; - const directorsProofInputID = PROOF_OF_DIRECTORS; - const codiceFiscaleInputID = CODICE_FISCALE; - const defaultValues: Record = { - [copyOfIDInputID]: Array.isArray(reimbursementAccountDraft?.[copyOfIDInputID]) ? (reimbursementAccountDraft?.[copyOfIDInputID] ?? []) : [], - [addressProofInputID]: Array.isArray(reimbursementAccountDraft?.[addressProofInputID]) ? (reimbursementAccountDraft?.[addressProofInputID] ?? []) : [], - [directorsProofInputID]: Array.isArray(reimbursementAccountDraft?.[directorsProofInputID]) ? (reimbursementAccountDraft?.[directorsProofInputID] ?? []) : [], - [codiceFiscaleInputID]: Array.isArray(reimbursementAccountDraft?.[codiceFiscaleInputID]) ? (reimbursementAccountDraft?.[codiceFiscaleInputID] ?? []) : [], + [COPY_OF_ID]: Array.isArray(reimbursementAccountDraft?.[COPY_OF_ID]) ? (reimbursementAccountDraft?.[COPY_OF_ID] ?? []) : [], + [ADDRESS_PROOF]: Array.isArray(reimbursementAccountDraft?.[ADDRESS_PROOF]) ? (reimbursementAccountDraft?.[ADDRESS_PROOF] ?? []) : [], + [PROOF_OF_DIRECTORS]: Array.isArray(reimbursementAccountDraft?.[PROOF_OF_DIRECTORS]) ? (reimbursementAccountDraft?.[PROOF_OF_DIRECTORS] ?? []) : [], + [CODICE_FISCALE]: Array.isArray(reimbursementAccountDraft?.[CODICE_FISCALE]) ? (reimbursementAccountDraft?.[CODICE_FISCALE] ?? []) : [], }; - const [uploadedIDs, setUploadedID] = useState(defaultValues[copyOfIDInputID]); - const [uploadedProofsOfAddress, setUploadedProofOfAddress] = useState(defaultValues[addressProofInputID]); - const [uploadedProofsOfDirectors, setUploadedProofsOfDirectors] = useState(defaultValues[directorsProofInputID]); - const [uploadedCodiceFiscale, setUploadedCodiceFiscale] = useState(defaultValues[codiceFiscaleInputID]); + const [uploadedIDs, setUploadedID] = useState(defaultValues[COPY_OF_ID]); + const [uploadedProofsOfAddress, setUploadedProofOfAddress] = useState(defaultValues[ADDRESS_PROOF]); + const [uploadedProofsOfDirectors, setUploadedProofsOfDirectors] = useState(defaultValues[PROOF_OF_DIRECTORS]); + const [uploadedCodiceFiscale, setUploadedCodiceFiscale] = useState(defaultValues[CODICE_FISCALE]); useEffect(() => { getEnvironmentURL().then(setEnvironmentUrl); }, []); - const STEP_FIELDS = useMemo( - (): Array> => [copyOfIDInputID, addressProofInputID, directorsProofInputID, codiceFiscaleInputID], - [copyOfIDInputID, addressProofInputID, directorsProofInputID, codiceFiscaleInputID], - ); + const STEP_FIELDS: Array> = [COPY_OF_ID, ADDRESS_PROOF, PROOF_OF_DIRECTORS, CODICE_FISCALE]; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - setIsPDSandFSGDownloadedTouched(true); - return getFieldRequiredErrors(values, STEP_FIELDS, translate); - }, - [STEP_FIELDS, translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => { + setIsPDSandFSGDownloadedTouched(true); + return getFieldRequiredErrors(values, STEP_FIELDS, translate); + }; const handleSubmit = useReimbursementAccountStepFormSubmit({ fieldIds: STEP_FIELDS, @@ -137,16 +126,16 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) { buttonText={translate('signerInfoStep.chooseFile')} uploadedFiles={uploadedIDs} onUpload={(files) => { - handleSelectFile(files, uploadedIDs, copyOfIDInputID, setUploadedID); + handleSelectFile(files, uploadedIDs, COPY_OF_ID, setUploadedID); }} onRemove={(fileName) => { - handleRemoveFile(fileName, uploadedIDs, copyOfIDInputID, setUploadedID); + handleRemoveFile(fileName, uploadedIDs, COPY_OF_ID, setUploadedID); }} acceptedFileTypes={[...CONST.NON_USD_BANK_ACCOUNT.ALLOWED_FILE_TYPES]} value={uploadedIDs} - inputID={copyOfIDInputID} + inputID={COPY_OF_ID} setError={(error) => { - setUploadError(error, copyOfIDInputID); + setUploadError(error, COPY_OF_ID); }} fileLimit={CONST.NON_USD_BANK_ACCOUNT.FILE_LIMIT} /> @@ -165,16 +154,16 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) { buttonText={translate('signerInfoStep.chooseFile')} uploadedFiles={uploadedProofsOfAddress} onUpload={(files) => { - handleSelectFile(files, uploadedProofsOfAddress, addressProofInputID, setUploadedProofOfAddress); + handleSelectFile(files, uploadedProofsOfAddress, ADDRESS_PROOF, setUploadedProofOfAddress); }} onRemove={(fileName) => { - handleRemoveFile(fileName, uploadedProofsOfAddress, addressProofInputID, setUploadedProofOfAddress); + handleRemoveFile(fileName, uploadedProofsOfAddress, ADDRESS_PROOF, setUploadedProofOfAddress); }} acceptedFileTypes={[...CONST.NON_USD_BANK_ACCOUNT.ALLOWED_FILE_TYPES]} value={uploadedProofsOfAddress} - inputID={addressProofInputID} + inputID={ADDRESS_PROOF} setError={(error) => { - setUploadError(error, addressProofInputID); + setUploadError(error, ADDRESS_PROOF); }} fileLimit={CONST.NON_USD_BANK_ACCOUNT.FILE_LIMIT} /> @@ -192,16 +181,16 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) { buttonText={translate('signerInfoStep.chooseFile')} uploadedFiles={uploadedProofsOfDirectors} onUpload={(files) => { - handleSelectFile(files, uploadedProofsOfDirectors, directorsProofInputID, setUploadedProofsOfDirectors); + handleSelectFile(files, uploadedProofsOfDirectors, PROOF_OF_DIRECTORS, setUploadedProofsOfDirectors); }} onRemove={(fileName) => { - handleRemoveFile(fileName, uploadedProofsOfDirectors, directorsProofInputID, setUploadedProofsOfDirectors); + handleRemoveFile(fileName, uploadedProofsOfDirectors, PROOF_OF_DIRECTORS, setUploadedProofsOfDirectors); }} acceptedFileTypes={[...CONST.NON_USD_BANK_ACCOUNT.ALLOWED_FILE_TYPES]} value={uploadedProofsOfDirectors} - inputID={directorsProofInputID} + inputID={PROOF_OF_DIRECTORS} setError={(error) => { - setUploadError(error, directorsProofInputID); + setUploadError(error, PROOF_OF_DIRECTORS); }} fileLimit={CONST.NON_USD_BANK_ACCOUNT.FILE_LIMIT} /> @@ -217,16 +206,16 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) { buttonText={translate('signerInfoStep.chooseFile')} uploadedFiles={uploadedCodiceFiscale} onUpload={(files) => { - handleSelectFile(files, uploadedCodiceFiscale, codiceFiscaleInputID, setUploadedCodiceFiscale); + handleSelectFile(files, uploadedCodiceFiscale, CODICE_FISCALE, setUploadedCodiceFiscale); }} onRemove={(fileName) => { - handleRemoveFile(fileName, uploadedCodiceFiscale, codiceFiscaleInputID, setUploadedCodiceFiscale); + handleRemoveFile(fileName, uploadedCodiceFiscale, CODICE_FISCALE, setUploadedCodiceFiscale); }} acceptedFileTypes={[...CONST.NON_USD_BANK_ACCOUNT.ALLOWED_FILE_TYPES]} value={uploadedCodiceFiscale} - inputID={codiceFiscaleInputID} + inputID={CODICE_FISCALE} setError={(error) => { - setUploadError(error, codiceFiscaleInputID); + setUploadError(error, CODICE_FISCALE); }} fileLimit={CONST.NON_USD_BANK_ACCOUNT.FILE_LIMIT} /> diff --git a/src/pages/inbox/ReactionListWrapper.tsx b/src/pages/inbox/ReactionListWrapper.tsx index 8d0372e232b9..17bbf9573e86 100644 --- a/src/pages/inbox/ReactionListWrapper.tsx +++ b/src/pages/inbox/ReactionListWrapper.tsx @@ -1,14 +1,105 @@ -import React, {useRef} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; +import type {SyntheticEvent} from 'react'; +import {Dimensions} from 'react-native'; import PopoverReactionList from './report/ReactionList/PopoverReactionList'; import {ReactionListContext} from './ReportScreenContext'; -import type {ReactionListRef} from './ReportScreenContext'; +import type {ReactionListAnchor, ReactionListContextType, ReactionListEvent} from './ReportScreenContext'; + +type AnchorPosition = {horizontal: number; vertical: number}; + +type ActiveReactionList = { + reportActionID: string; + emojiName: string; + cursorRelativePosition: AnchorPosition; + anchorPosition: AnchorPosition; +}; + +function getAnchorOrigin(anchor: ReactionListAnchor | null): {x: number; y: number} { + if (!anchor) { + return {x: 0, y: 0}; + } + const rect = anchor.getBoundingClientRect(); + return {x: rect.left, y: rect.top}; +} + +function getNativeMouseEvent(event: ReactionListEvent | undefined): {pageX: number; pageY: number} { + if (!event) { + return {pageX: 0, pageY: 0}; + } + const synthetic = event as SyntheticEvent; + const native = synthetic.nativeEvent as {pageX?: number; pageY?: number} | undefined; + return {pageX: native?.pageX ?? 0, pageY: native?.pageY ?? 0}; +} function ReactionListWrapper({children}: {children: React.ReactNode}) { - const reactionListRef = useRef(null); + const [activeReactionList, setActiveReactionList] = useState(null); + const anchorRef = useRef(null); + + const showReactionList = (event: ReactionListEvent | undefined, reactionListAnchor: ReactionListAnchor, emojiName: string, reportActionID: string): void => { + const {pageX, pageY} = getNativeMouseEvent(event); + const {x, y} = getAnchorOrigin(reactionListAnchor); + anchorRef.current = reactionListAnchor; + setActiveReactionList({ + reportActionID, + emojiName, + cursorRelativePosition: {horizontal: pageX - x, vertical: pageY - y}, + anchorPosition: {horizontal: pageX, vertical: pageY}, + }); + }; + + const hideReactionList = (): void => { + anchorRef.current = null; + setActiveReactionList(null); + }; + + const isActiveReportAction = (reportActionID: number | string): boolean => !!reportActionID && !!activeReactionList && activeReactionList.reportActionID === reportActionID; + + const isVisible = activeReactionList !== null; + const cursorHorizontal = activeReactionList?.cursorRelativePosition.horizontal ?? 0; + const cursorVertical = activeReactionList?.cursorRelativePosition.vertical ?? 0; + + // Recompute the popover anchor position when window dimensions change so the popover + // stays anchored to the reaction bubble after rotation/resize. + useEffect(() => { + if (!isVisible) { + return; + } + const dimensionsEventListener = Dimensions.addEventListener('change', () => { + const {x, y} = getAnchorOrigin(anchorRef.current); + if (!x || !y) { + return; + } + setActiveReactionList((prev) => { + if (!prev) { + return prev; + } + return { + ...prev, + anchorPosition: { + horizontal: prev.cursorRelativePosition.horizontal + x, + vertical: prev.cursorRelativePosition.vertical + y, + }, + }; + }); + }); + return () => { + dimensionsEventListener.remove(); + }; + }, [isVisible, cursorHorizontal, cursorVertical]); + + const contextValue: ReactionListContextType = {showReactionList, hideReactionList, isActiveReportAction}; + return ( - + {children} - + ); } diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 74e038890c2a..6790e427478a 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -72,10 +72,10 @@ function ReportScreen({route, navigation}: ReportScreenProps) { if (!shouldDeferNonEssentials) { return; } - let animationFrameId: number; + const animationFrameRef = {current: 0}; const handle = TransitionTracker.runAfterTransitions({ callback: () => { - animationFrameId = requestAnimationFrame(() => setShouldDeferNonEssentials(false)); + animationFrameRef.current = requestAnimationFrame(() => setShouldDeferNonEssentials(false)); }, waitForUpcomingTransition: true, }); @@ -84,7 +84,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const safetyTimeout = setTimeout(() => setShouldDeferNonEssentials(false), CONST.MAX_TRANSITION_DURATION_MS * 3); return () => { handle.cancel(); - cancelAnimationFrame(animationFrameId); + cancelAnimationFrame(animationFrameRef.current); clearTimeout(safetyTimeout); }; }, [shouldDeferNonEssentials]), diff --git a/src/pages/inbox/ReportScreenContext.ts b/src/pages/inbox/ReportScreenContext.ts index 50b1e3e707f9..9102cdba56d3 100644 --- a/src/pages/inbox/ReportScreenContext.ts +++ b/src/pages/inbox/ReportScreenContext.ts @@ -7,10 +7,10 @@ type ReactionListAnchor = View | Text | HTMLDivElement | null; type ReactionListEvent = GestureResponderEvent | MouseEvent | SyntheticEvent; -type ReactionListRef = { +type ReactionListContextType = { showReactionList: (event: ReactionListEvent | undefined, reactionListAnchor: ReactionListAnchor, emojiName: string, reportActionID: string) => void; hideReactionList: () => void; - isActiveReportAction: (actionID: number | string) => boolean; + isActiveReportAction: (reportActionID: number | string) => boolean; }; type FlatListRefType = RefObject | null> | null; @@ -22,10 +22,13 @@ type ActionListContextType = { scrollPositionRef: RefObject; scrollOffsetRef: RefObject; }; -type ReactionListContextType = RefObject | null; const ActionListContext = createContext({flatListRef: null, scrollPositionRef: {current: {}}, scrollOffsetRef: {current: 0}}); -const ReactionListContext = createContext(null); +const ReactionListContext = createContext({ + showReactionList: () => {}, + hideReactionList: () => {}, + isActiveReportAction: () => false, +}); export {ActionListContext, ReactionListContext}; -export type {ReactionListRef, ActionListContextType, FlatListRefType, ReactionListAnchor, ReactionListEvent, ScrollPosition}; +export type {ReactionListContextType, ActionListContextType, FlatListRefType, ReactionListAnchor, ReactionListEvent, ScrollPosition}; diff --git a/src/pages/inbox/report/PureReportActionItem.tsx b/src/pages/inbox/report/PureReportActionItem.tsx index 9b693721dc19..c00debdda5c1 100644 --- a/src/pages/inbox/report/PureReportActionItem.tsx +++ b/src/pages/inbox/report/PureReportActionItem.tsx @@ -363,7 +363,7 @@ function PureReportActionItem({ const shouldRenderViewBasedOnAction = useTableReportViewActionRenderConditionals(action); const [isHidden, setIsHidden] = useState(false); const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED); - const reactionListRef = useContext(ReactionListContext); + const {isActiveReportAction: isActiveReactionListReportAction, hideReactionList} = useContext(ReactionListContext); const {updateHiddenAttachments} = useContext(AttachmentModalContext); const composerTextInputRef = useRef(null); const popoverAnchorRef = useRef>(null); @@ -464,11 +464,11 @@ function PureReportActionItem({ if (isActive(action.reportActionID)) { hideEmojiPicker(true); } - if (reactionListRef?.current?.isActiveReportAction(action.reportActionID)) { - reactionListRef?.current?.hideReactionList(); + if (isActiveReactionListReportAction(action.reportActionID)) { + hideReactionList(); } }, - [action.reportActionID, reactionListRef], + [action.reportActionID, isActiveReactionListReportAction, hideReactionList], ); useEffect(() => { diff --git a/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx b/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx new file mode 100644 index 000000000000..ff364f697142 --- /dev/null +++ b/src/pages/inbox/report/ReactionList/PopoverReactionList.tsx @@ -0,0 +1,68 @@ +import React, {useEffect} from 'react'; +import type {RefObject} from 'react'; +import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import {getEmojiReactionDetails} from '@libs/EmojiUtils'; +import {getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils'; +import type {ReactionListAnchor} from '@pages/inbox/ReportScreenContext'; +import ONYXKEYS from '@src/ONYXKEYS'; +import BaseReactionList from './BaseReactionList'; + +type PopoverReactionListProps = { + isVisible: boolean; + emojiName: string; + reportActionID: string | undefined; + anchorPosition: {horizontal: number; vertical: number}; + anchorRef: RefObject; + onClose: () => void; +}; + +function PopoverReactionList({isVisible, emojiName, reportActionID, anchorPosition, anchorRef, onClose}: PopoverReactionListProps) { + const {accountID} = useCurrentUserPersonalDetails(); + + const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`); + + const selectedReaction = emojiReactions?.[emojiName]; + const isReady = !!selectedReaction; + const {emojiCodes = [], reactionCount = 0, hasUserReacted = false, userAccountIDs = []} = selectedReaction ? getEmojiReactionDetails(emojiName, selectedReaction, accountID) : {}; + const users = isReady ? getPersonalDetailsByIDs({accountIDs: userAccountIDs, currentUserAccountID: accountID, shouldChangeUserDisplayName: true}) : []; + + // Hide the list when all reactions are removed + useEffect(() => { + if (!isVisible) { + return; + } + const reactionUsers = emojiReactions?.[emojiName]?.users; + if (!reactionUsers || Object.keys(reactionUsers).length > 0) { + return; + } + onClose(); + }, [emojiReactions, emojiName, isVisible, onClose]); + + return ( + + + + ); +} + +export default PopoverReactionList; diff --git a/src/pages/inbox/report/ReactionList/PopoverReactionList/BasePopoverReactionList.tsx b/src/pages/inbox/report/ReactionList/PopoverReactionList/BasePopoverReactionList.tsx deleted file mode 100644 index 6f65e72888e6..000000000000 --- a/src/pages/inbox/report/ReactionList/PopoverReactionList/BasePopoverReactionList.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, {useImperativeHandle} from 'react'; -import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; -import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import useBasePopoverReactionList from '@hooks/useBasePopoverReactionList'; -import type {BasePopoverReactionListProps} from '@hooks/useBasePopoverReactionList/types'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import BaseReactionList from '@pages/inbox/report/ReactionList/BaseReactionList'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -type PopoverReactionListProps = WithCurrentUserPersonalDetailsProps & BasePopoverReactionListProps; - -function BasePopoverReactionList({emojiName, reportActionID, currentUserPersonalDetails, ref}: PopoverReactionListProps) { - const {preferredLocale} = useLocalize(); - - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const reactionReportActionID = reportActionID || CONST.DEFAULT_NUMBER_ID; - const [emojiReactions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reactionReportActionID}`); - const {isPopoverVisible, hideReactionList, showReactionList, popoverAnchorPosition, reactionListRef, getReactionInformation} = useBasePopoverReactionList({ - emojiName, - emojiReactions, - accountID: currentUserPersonalDetails.accountID, - reportActionID, - preferredLocale, - }); - - // Get the reaction information - const {emojiCodes, reactionCount, hasUserReacted, users, isReady} = getReactionInformation(); - useImperativeHandle(ref, () => ({hideReactionList, showReactionList})); - - return ( - - - - ); -} - -export default withCurrentUserPersonalDetails(BasePopoverReactionList); diff --git a/src/pages/inbox/report/ReactionList/PopoverReactionList/index.tsx b/src/pages/inbox/report/ReactionList/PopoverReactionList/index.tsx deleted file mode 100644 index c6a29e2c1af9..000000000000 --- a/src/pages/inbox/report/ReactionList/PopoverReactionList/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, {useImperativeHandle, useRef, useState} from 'react'; -import type {ForwardedRef} from 'react'; -import type {InnerReactionListRef} from '@hooks/useBasePopoverReactionList/types'; -import type {ReactionListRef} from '@pages/inbox/ReportScreenContext'; -import BasePopoverReactionList from './BasePopoverReactionList'; - -type PopoverReactionListProps = { - /** Reference to the outer element */ - ref?: ForwardedRef; -}; - -function PopoverReactionList({ref}: PopoverReactionListProps) { - const innerReactionListRef = useRef(null); - const [reactionListReportActionID, setReactionListReportActionID] = useState(''); - const [reactionListEmojiName, setReactionListEmojiName] = useState(''); - - const showReactionList: ReactionListRef['showReactionList'] = (event, reactionListAnchor, emojiName, reportActionID) => { - setReactionListReportActionID(reportActionID); - setReactionListEmojiName(emojiName); - innerReactionListRef.current?.showReactionList(event, reactionListAnchor); - }; - - const hideReactionList = () => { - innerReactionListRef.current?.hideReactionList(); - }; - - const isActiveReportAction = (actionID: number | string) => !!actionID && reactionListReportActionID === actionID; - - useImperativeHandle(ref, () => ({showReactionList, hideReactionList, isActiveReportAction})); - - return ( - - ); -} - -PopoverReactionList.displayName = 'PopoverReactionList'; - -export default React.memo(PopoverReactionList); diff --git a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx index f233f3a63152..1a1bb949be5e 100644 --- a/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/report/ReportAttachmentModalContent.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import type {View} from 'react-native'; import type {Attachment} from '@components/Attachments/types'; import useNetwork from '@hooks/useNetwork'; @@ -55,48 +55,37 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen const submitRef = useRef(null); - const shouldFetchReport = useMemo(() => { - return isEmptyObject(reportActions?.[reportActionID ?? CONST.DEFAULT_NUMBER_ID]); - }, [reportActions, reportActionID]); + const shouldFetchReport = isEmptyObject(reportActions?.[reportActionID ?? CONST.DEFAULT_NUMBER_ID]); - const isLoading = useMemo(() => { - if (isOffline || isReportNotFound(report) || !reportActionReportID) { - return false; - } + let isLoading = false; + if (!(isOffline || isReportNotFound(report) || !reportActionReportID)) { const isEmptyReport = isEmptyObject(report); - return !!isLoadingApp || isEmptyReport || (reportLoadingState?.isLoadingInitialReportActions !== false && shouldFetchReport); - }, [isOffline, reportActionReportID, isLoadingApp, report, reportLoadingState?.isLoadingInitialReportActions, shouldFetchReport]); - - const fetchReport = useCallback(() => { - openReport({reportID: reportActionReportID, introSelected, reportActionID, betas}); - }, [reportActionReportID, introSelected, reportActionID, betas]); + isLoading = !!isLoadingApp || isEmptyReport || (reportLoadingState?.isLoadingInitialReportActions !== false && shouldFetchReport); + } useEffect(() => { if (!reportActionReportID || !shouldFetchReport) { return; } - fetchReport(); - }, [reportActionReportID, fetchReport, shouldFetchReport]); - - const onCarouselAttachmentChange = useCallback( - (attachment: Attachment) => { - const routeToNavigate = ROUTES.REPORT_ATTACHMENTS.getRoute({ - reportID, - reportActionID: attachment.reportActionID, - attachmentID: attachment.attachmentID, - type, - source: SafeString(attachment.source), - accountID, - isAuthTokenRequired: attachment?.isAuthTokenRequired, - originalFileName: attachment?.file?.name, - attachmentLink: attachment?.attachmentLink, - hashKey, - }); - Navigation.navigate(routeToNavigate); - }, - [reportID, reportActionID, type, accountID, hashKey], - ); + openReport({reportID: reportActionReportID, introSelected, reportActionID, betas}); + }, [reportActionReportID, shouldFetchReport, introSelected, reportActionID, betas]); + + const onCarouselAttachmentChange = (attachment: Attachment) => { + const routeToNavigate = ROUTES.REPORT_ATTACHMENTS.getRoute({ + reportID, + reportActionID: attachment.reportActionID, + attachmentID: attachment.attachmentID, + type, + source: SafeString(attachment.source), + accountID, + isAuthTokenRequired: attachment?.isAuthTokenRequired, + originalFileName: attachment?.file?.name, + attachmentLink: attachment?.attachmentLink, + hashKey, + }); + Navigation.navigate(routeToNavigate); + }; const onDownloadAttachment = useDownloadAttachment({ isAuthTokenRequired, @@ -105,46 +94,29 @@ function ReportAttachmentModalContent({route, navigation}: AttachmentModalScreen // Skip API root normalization for search attachments because this route is only opened from preview, // which already passes a resolved source. Keep normalization for other types to support email entry points. - const source = useMemo(() => getValidatedImageSource(sourceParam, type !== CONST.ATTACHMENT_TYPE.SEARCH), [sourceParam, type]); + const source = getValidatedImageSource(sourceParam, type !== CONST.ATTACHMENT_TYPE.SEARCH); const modalType = useReportAttachmentModalType(source); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = !isLoading && type !== CONST.ATTACHMENT_TYPE.SEARCH && !report?.reportID; - const contentProps = useMemo( - () => ({ - // In native the imported images sources are of type number. Ref: https://reactnative.dev/docs/image#imagesource - type, - report, - shouldShowNotFoundPage, - isAuthTokenRequired: !!isAuthTokenRequired, - attachmentLink: attachmentLink ?? '', - originalFileName: originalFileName ?? '', - isLoading, - source, - attachmentID, - accountID, - headerTitle, - submitRef, - onDownloadAttachment, - onCarouselAttachmentChange, - }), - [ - accountID, - attachmentID, - attachmentLink, - headerTitle, - isAuthTokenRequired, - isLoading, - onCarouselAttachmentChange, - onDownloadAttachment, - originalFileName, - report, - shouldShowNotFoundPage, - source, - type, - ], - ); + const contentProps: AttachmentModalBaseContentProps = { + // In native the imported images sources are of type number. Ref: https://reactnative.dev/docs/image#imagesource + type, + report, + shouldShowNotFoundPage, + isAuthTokenRequired: !!isAuthTokenRequired, + attachmentLink: attachmentLink ?? '', + originalFileName: originalFileName ?? '', + isLoading, + source, + attachmentID, + accountID, + headerTitle, + submitRef, + onDownloadAttachment, + onCarouselAttachmentChange, + }; return ( diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 4cc3ebef27d1..c29caceee28f 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -3,13 +3,12 @@ import {differenceInDays} from 'date-fns'; import {stopLocationUpdatesAsync} from 'expo-location'; import React, {useContext, useEffect, useLayoutEffect, useRef} from 'react'; // eslint-disable-next-line no-restricted-imports -import type {GestureResponderEvent, ScrollView as RNScrollView, ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; +import type {ScrollView as RNScrollView, ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import AccountSwitcher from '@components/AccountSwitcher'; import AccountSwitcherSkeletonView from '@components/AccountSwitcherSkeletonView'; import Icon from '@components/Icon'; -import MenuItem from '@components/MenuItem'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; @@ -49,7 +48,6 @@ import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan import {shouldHideOldAppRedirect} from '@libs/TryNewDotUtils'; import {getProfilePageBrickRoadIndicator} from '@libs/UserUtils'; import type SETTINGS_TO_RHP from '@navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP'; -import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import {BACKGROUND_LOCATION_TRACKING_TASK_NAME} from '@pages/iou/request/step/IOURequestStepDistanceGPS/const'; import {stopGpsTripNotification} from '@pages/iou/request/step/IOURequestStepDistanceGPS/GPSNotifications'; import variables from '@styles/variables'; @@ -71,6 +69,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import SettingsMenuItem from './SettingsMenuItem'; type InitialSettingsPageProps = WithCurrentUserPersonalDetailsProps; @@ -101,6 +100,8 @@ type MenuData = WithSentryLabel & { type Menu = {sectionStyle: StyleProp; sectionTranslationKey: TranslationPaths; items: MenuData[]}; +export type {MenuData}; + function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPageProps) { const {convertToDisplayString} = useCurrencyListActions(); const icons = useMemoizedLazyExpensifyIcons([ @@ -144,7 +145,6 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const theme = useTheme(); const styles = useThemeStyles(); const {isExecuting, singleExecution} = useSingleExecution(); - const popoverAnchor = useRef(null); const {translate} = useLocalize(); const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); const emojiCode = currentUserPersonalDetails?.status?.emojiCode ?? ''; @@ -416,30 +416,6 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr * @returns the menu items for passed data */ const getMenuItemsSection = (menuItemsData: Menu) => { - const openPopover = (link: string | (() => Promise) | undefined, event: GestureResponderEvent | MouseEvent) => { - if (!isScreenFocused) { - return; - } - - if (typeof link === 'function') { - link?.()?.then((url) => - showContextMenu({ - type: CONST.CONTEXT_MENU_TYPES.LINK, - event, - selection: url, - contextMenuAnchor: popoverAnchor.current, - }), - ); - } else if (link) { - showContextMenu({ - type: CONST.CONTEXT_MENU_TYPES.LINK, - event, - selection: link, - contextMenuAnchor: popoverAnchor.current, - }); - } - }; - return ( openPopover(item.link, event) : undefined} - shouldShowContextMenuHint={!!item.link} - focused={isFocused} - role={CONST.ROLE.TAB} - isPaneMenu - sentryLabel={item.sentryLabel} - iconRight={item.iconRight} - shouldShowRightIcon={item.shouldShowRightIcon} - shouldIconUseAutoWidthStyle + wrapperStyle={styles.sectionMenuItem(shouldUseNarrowLayout)} /> ); })} diff --git a/src/pages/settings/SettingsMenuItem.tsx b/src/pages/settings/SettingsMenuItem.tsx new file mode 100644 index 000000000000..d0ad3258518f --- /dev/null +++ b/src/pages/settings/SettingsMenuItem.tsx @@ -0,0 +1,81 @@ +import React, {useRef} from 'react'; +import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; +import MenuItem from '@components/MenuItem'; +import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {MenuData} from './InitialSettingsPage'; + +type SettingsMenuItemProps = { + item: MenuData; + isFocused: boolean; + keyTitle: string | undefined; + isExecuting: boolean; + isScreenFocused: boolean; + onPress: () => void; + wrapperStyle: StyleProp; +}; + +function SettingsMenuItem({item, isFocused, keyTitle, isExecuting, isScreenFocused, onPress, wrapperStyle}: SettingsMenuItemProps) { + const popoverAnchor = useRef(null); + + const onSecondaryInteraction = item.link + ? (event: GestureResponderEvent | MouseEvent) => { + if (!isScreenFocused) { + return; + } + const {link} = item; + if (typeof link === 'function') { + link()?.then((url) => + showContextMenu({ + type: CONST.CONTEXT_MENU_TYPES.LINK, + event, + selection: url, + contextMenuAnchor: popoverAnchor.current, + }), + ); + } else if (link) { + showContextMenu({ + type: CONST.CONTEXT_MENU_TYPES.LINK, + event, + selection: link, + contextMenuAnchor: popoverAnchor.current, + }); + } + } + : undefined; + + return ( + + ); +} + +SettingsMenuItem.displayName = 'SettingsMenuItem'; + +export default SettingsMenuItem; diff --git a/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx b/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx index 6481a3c7f72d..d2461603a43e 100644 --- a/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx +++ b/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx @@ -1,5 +1,4 @@ -import type {ReactNode} from 'react'; -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Button from '@components/Button'; @@ -20,7 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {cancelBillingSubscription} from '@libs/actions/Subscription'; import Navigation from '@libs/Navigation/Navigation'; import {canCancelSubscription} from '@libs/SubscriptionUtils'; -import type {CancellationType, FeedbackSurveyOptionID} from '@src/CONST'; +import type {FeedbackSurveyOptionID} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -47,98 +46,25 @@ function CancelSubscriptionPage() { // Falls back to reading cancellation details directly on remount (hook only detects array growth). // Skipped for eligible users so they see the survey, not a historical success screen. - const resolvedCancellationType = useMemo(() => { - if (cancellationTypeFromHook) { - return cancellationTypeFromHook; - } - if (isEligibleToCancel || !cancellationDetails?.length) { - return undefined; - } + let resolvedCancellationType = cancellationTypeFromHook; + if (!resolvedCancellationType && !isEligibleToCancel && cancellationDetails?.length) { const pendingManual = cancellationDetails.find((detail) => detail.cancellationType === CONST.CANCELLATION_TYPE.MANUAL && !detail.cancellationDate); - if (pendingManual) { - return CONST.CANCELLATION_TYPE.MANUAL; - } const noneEntry = cancellationDetails.find((detail) => detail.cancellationType === CONST.CANCELLATION_TYPE.NONE); - if (noneEntry) { - return CONST.CANCELLATION_TYPE.NONE; + if (pendingManual) { + resolvedCancellationType = CONST.CANCELLATION_TYPE.MANUAL; + } else if (noneEntry) { + resolvedCancellationType = CONST.CANCELLATION_TYPE.NONE; + } else { + resolvedCancellationType = CONST.CANCELLATION_TYPE.AUTOMATIC; } - return CONST.CANCELLATION_TYPE.AUTOMATIC; - }, [cancellationTypeFromHook, cancellationDetails, isEligibleToCancel]); + } const handleSubmit = (cancellationReason: FeedbackSurveyOptionID, cancellationNote = '') => { cancelBillingSubscription(cancellationReason, cancellationNote); }; - const acknowledgementText = useMemo(() => , [translate]); - - const manualCancellationContent = useMemo( - () => ( - - - {translate('subscription.cancelSubscription.requestSubmitted.title')} - - - - - -