diff --git a/src/CONST.ts b/src/CONST.ts index 5e0b175d1227b..69ea73ac6a7c0 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1342,7 +1342,7 @@ const CONST = { }, }, THREAD_DISABLED: ['CREATED'], - // Used when displaying reportActions list to handle of unread messages icon/button + // Used when displaying reportActions list to handle unread messages icon/button SCROLL_VERTICAL_OFFSET_THRESHOLD: 200, ACTION_VISIBLE_THRESHOLD: 250, }, diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 5bf6897c6cb2a..c902cecb75457 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,8 +1,8 @@ import type {ListRenderItemInfo} from '@react-native/virtualized-lists/Lists/VirtualizedList'; import isEmpty from 'lodash/isEmpty'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {InteractionManager, View} from 'react-native'; +import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import FlatList from '@components/FlatList'; @@ -13,6 +13,8 @@ import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus'; import usePrevious from '@hooks/usePrevious'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import {parseFSAttributes} from '@libs/Fullstory'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isActionVisibleOnMoneyRequestReport} from '@libs/MoneyRequestReportUtils'; import { @@ -22,9 +24,6 @@ import { hasNextActionMadeBySameActor, isConsecutiveChronosAutomaticTimerAction, isDeletedParentAction, - isReportActionUnread, - isReportPreviewAction, - shouldHideNewMarker, shouldReportActionBeVisible, wasMessageReceivedWhileOffline, } from '@libs/ReportActionsUtils'; @@ -33,7 +32,8 @@ import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostF import Navigation from '@navigation/Navigation'; import FloatingMessageCounter from '@pages/home/report/FloatingMessageCounter'; import ReportActionsListItemRenderer from '@pages/home/report/ReportActionsListItemRenderer'; -import {openReport, readNewestAction} from '@userActions/Report'; +import shouldDisplayNewMarkerOnReportAction from '@pages/home/report/shouldDisplayNewMarkerOnReportAction'; +import {openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -47,6 +47,8 @@ import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyS const EmptyParentReportActionForTransactionThread = undefined; const INITIAL_NUM_TO_RENDER = 20; +// Amount of time to wait until all list items should be rendered and scrollToEnd will behave well +const DELAY_FOR_SCROLLING_TO_END = 100; type MoneyRequestReportListProps = { /** The report */ @@ -72,15 +74,12 @@ function getParentReportAction(parentReportActions: OnyxEntry { const filteredActions = reportActions.filter((reportAction) => { @@ -115,6 +112,18 @@ function MoneyRequestReportActionsList({report, reportActions = [], transactions return filteredActions.toReversed(); }, [reportActions, isOffline, canPerformWriteAction]); + const reportActionSize = useRef(visibleReportActions.length); + const lastAction = visibleReportActions.at(-1); + const lastActionIndex = lastAction?.reportActionID; + const previousLastIndex = useRef(lastActionIndex); + + const scrollingVerticalBottomOffset = useRef(0); + const readActionSkipped = useRef(false); + const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); + const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated; + const hasNewestReportActionRef = useRef(hasNewestReportAction); + const userActiveSince = useRef(DateUtils.getDBTime()); + const reportActionIDs = useMemo(() => { return reportActions?.map((action) => action.reportActionID) ?? []; }, [reportActions]); @@ -130,31 +139,20 @@ function MoneyRequestReportActionsList({report, reportActions = [], transactions const onStartReached = useCallback(() => { if (!isSearchTopmostFullScreenRoute()) { - loadNewerChats(false); + loadOlderChats(false); return; } - InteractionManager.runAfterInteractions(() => requestAnimationFrame(() => loadNewerChats(false))); - }, [loadNewerChats]); - - const onEndReached = useCallback(() => { - loadOlderChats(false); + InteractionManager.runAfterInteractions(() => requestAnimationFrame(() => loadOlderChats(false))); }, [loadOlderChats]); - const reportActionSize = useRef(visibleReportActions.length); - const lastAction = visibleReportActions.at(-1); - const lastActionIndex = lastAction?.reportActionID; - const previousLastIndex = useRef(lastActionIndex); - - const scrollingVerticalOffset = useRef(0); - const readActionSkipped = useRef(false); - const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); - const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated; - const hasNewestReportActionRef = useRef(hasNewestReportAction); + const onEndReached = useCallback(() => { + loadNewerChats(false); + }, [loadNewerChats]); useEffect(() => { if ( - scrollingVerticalOffset.current < AUTOSCROLL_TO_TOP_THRESHOLD && + scrollingVerticalBottomOffset.current < AUTOSCROLL_TO_TOP_THRESHOLD && previousLastIndex.current !== lastActionIndex && reportActionSize.current > reportActions.length && hasNewestReportAction @@ -178,6 +176,8 @@ function MoneyRequestReportActionsList({report, reportActions = [], transactions }, [visibleReportActions]); const prevVisibleActionsMap = usePrevious(visibleActionsMap); + const reportLastReadTime = report.lastReadTime ?? ''; + /** * The timestamp for the unread marker. * @@ -186,9 +186,9 @@ function MoneyRequestReportActionsList({report, reportActions = [], transactions * - marks a message as read/unread * - reads a new message as it is received */ - const [unreadMarkerTime, setUnreadMarkerTime] = useState(report.lastReadTime); + const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime); useEffect(() => { - setUnreadMarkerTime(report.lastReadTime); + setUnreadMarkerTime(reportLastReadTime); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [report.reportID]); @@ -206,72 +206,114 @@ function MoneyRequestReportActionsList({report, reportActions = [], transactions }, [isOffline, lastOfflineAt, lastOnlineAt, preferredLocale, reportActions]); /** - * TODO extract as reusable logic from ReportActionsList - https://github.com/Expensify/App/issues/58891 + * The reportActionID the unread marker should display above */ const unreadMarkerReportActionID = useMemo(() => { - const shouldDisplayNewMarker = (message: OnyxTypes.ReportAction, index: number): boolean => { - const nextMessage = visibleReportActions.at(index + 1); - const isNextMessageUnread = !!nextMessage && isReportActionUnread(nextMessage, unreadMarkerTime); + // If there are message that were received while offline, + // we can skip checking all messages later than the earliest received offline message. + const startIndex = visibleReportActions.length - 1; + const endIndex = earliestReceivedOfflineMessageIndex ?? 0; - // If the current message is the earliest message received while offline, we want to display the unread marker above this message. + // Scan through each visible report action until we find the appropriate action to show the unread marker + for (let index = startIndex; index >= endIndex; index--) { + const reportAction = visibleReportActions.at(index); + const nextAction = visibleReportActions.at(index - 1); const isEarliestReceivedOfflineMessage = index === earliestReceivedOfflineMessageIndex; - if (isEarliestReceivedOfflineMessage && !isNextMessageUnread) { - return true; - } - // If the unread marker should be hidden or is not within the visible area, don't show the unread marker. - if (shouldHideNewMarker(message)) { - return false; - } + const shouldDisplayNewMarker = + reportAction && + shouldDisplayNewMarkerOnReportAction({ + message: reportAction, + nextMessage: nextAction, + isEarliestReceivedOfflineMessage, + accountID: currentUserAccountID, + prevSortedVisibleReportActionsObjects: prevVisibleActionsMap, + unreadMarkerTime, + scrollingVerticalOffset: scrollingVerticalBottomOffset.current, + prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, + }); - const isCurrentMessageUnread = isReportActionUnread(message, unreadMarkerTime); - - // If the current message is read or the next message is unread, don't show the unread marker. - if (!isCurrentMessageUnread || isNextMessageUnread) { - return false; + // eslint-disable-next-line react-compiler/react-compiler + if (shouldDisplayNewMarker) { + return reportAction.reportActionID; } + } - const isPendingAdd = (action: OnyxTypes.ReportAction) => { - return action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; - }; - - // If no unread marker exists, don't set an unread marker for newly added messages from the current user. - const isFromCurrentUser = currentUserAccountID === (isReportPreviewAction(message) ? message.childLastActorAccountID : message.actorAccountID); - const isNewMessage = !prevVisibleActionsMap[message.reportActionID]; - - // The unread marker will show if the action's `created` time is later than `unreadMarkerTime`. - // The `unreadMarkerTime` has already been updated to match the optimistic action created time, - // but once the new action is saved on the backend, the actual created time will be later than the optimistic one. - // Therefore, we also need to prevent the unread marker from appearing for previously optimistic actions. - const isPreviouslyOptimistic = - (isPendingAdd(prevVisibleActionsMap[message.reportActionID]) && !isPendingAdd(message)) || - (!!prevVisibleActionsMap[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction); - const shouldIgnoreUnreadForCurrentUserMessage = !prevUnreadMarkerReportActionID.current && isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic); - - if (isFromCurrentUser) { - return !shouldIgnoreUnreadForCurrentUserMessage; - } + return null; + }, [currentUserAccountID, earliestReceivedOfflineMessageIndex, prevVisibleActionsMap, visibleReportActions, unreadMarkerTime]); + prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; + + /** + * Subscribe to read/unread events and update our unreadMarkerTime + */ + useEffect(() => { + const unreadActionSubscription = DeviceEventEmitter.addListener(`unreadAction_${report.reportID}`, (newLastReadTime: string) => { + setUnreadMarkerTime(newLastReadTime); + userActiveSince.current = DateUtils.getDBTime(); + }); + const readNewestActionSubscription = DeviceEventEmitter.addListener(`readNewestAction_${report.reportID}`, (newLastReadTime: string) => { + setUnreadMarkerTime(newLastReadTime); + }); - return !isNewMessage || scrollingVerticalOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + return () => { + unreadActionSubscription.remove(); + readNewestActionSubscription.remove(); }; + }, [report.reportID]); - // If there are message that were recevied while offline, - // we can skip checking all messages later than the earliest recevied offline message. - const startIndex = earliestReceivedOfflineMessageIndex ?? 0; + /** + * When the user reads a new message as it is received, we'll push the unreadMarkerTime down to the timestamp of + * the latest report action. When new report actions are received and the user is not viewing them (they're above + * the MSG_VISIBLE_THRESHOLD), the unread marker will display over those new messages rather than the initial + * lastReadTime. + */ + useLayoutEffect(() => { + if (unreadMarkerReportActionID) { + return; + } - // Scan through each visible report action until we find the appropriate action to show the unread marker - for (let index = startIndex; index < visibleReportActions.length; index++) { - const reportAction = visibleReportActions.at(index); + const mostRecentReportActionCreated = lastAction?.created ?? ''; + if (mostRecentReportActionCreated <= unreadMarkerTime) { + return; + } - // eslint-disable-next-line react-compiler/react-compiler - if (reportAction && shouldDisplayNewMarker(reportAction, index)) { - return reportAction.reportActionID; + setUnreadMarkerTime(mostRecentReportActionCreated); + }, [lastAction?.created, unreadMarkerReportActionID, unreadMarkerTime]); + + const scrollToBottomForCurrentUserAction = useCallback( + (isFromCurrentUser: boolean) => { + InteractionManager.runAfterInteractions(() => { + setIsFloatingMessageCounterVisible(false); + // If a new comment is added from the current user, scroll to the bottom, otherwise leave the user position unchanged + if (!isFromCurrentUser) { + return; + } + + // We want to scroll to the end of the list where the newest message is + // however scrollToEnd will not work correctly with items of variable sizes without `getItemLayout` - so we need to delay the scroll until every item rendered + setTimeout(() => { + reportScrollManager.scrollToEnd(); + }, DELAY_FOR_SCROLLING_TO_END); + }); + }, + [reportScrollManager], + ); + + useEffect(() => { + // This callback is triggered when a new action arrives via Pusher and the event is emitted from Report.ts. This allows us to maintain + // a single source of truth for the "new action" event instead of trying to derive that a new action has appeared from looking at props. + const unsubscribe = subscribeToNewActionEvent(report.reportID, scrollToBottomForCurrentUserAction); + + return () => { + if (!unsubscribe) { + return; } - } + unsubscribe(); + }; - return null; - }, [currentUserAccountID, earliestReceivedOfflineMessageIndex, prevVisibleActionsMap, visibleReportActions, unreadMarkerTime]); - prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; + // This effect handles subscribing to events, so we only want to run it on mount, and in case reportID changes + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [report.reportID]); const renderItem = useCallback( ({item: reportAction, index}: ListRenderItemInfo) => { @@ -320,11 +362,11 @@ function MoneyRequestReportActionsList({report, reportActions = [], transactions * Show/hide the new floating message counter when user is scrolling back/forth in the history of messages. */ const handleUnreadFloatingButton = () => { - if (scrollingVerticalOffset.current > CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && !isFloatingMessageCounterVisible && !!unreadMarkerReportActionID) { + if (scrollingVerticalBottomOffset.current > CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && !isFloatingMessageCounterVisible && !!unreadMarkerReportActionID) { setIsFloatingMessageCounterVisible(true); } - if (scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible) { + if (scrollingVerticalBottomOffset.current < CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible) { if (readActionSkipped.current) { readActionSkipped.current = false; readNewestAction(report.reportID); @@ -333,12 +375,23 @@ function MoneyRequestReportActionsList({report, reportActions = [], transactions } }; - const reportHasComments = visibleReportActions.length > 0; const trackVerticalScrolling = (event: NativeSyntheticEvent) => { - scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; + const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; + const fullContentHeight = contentSize.height; + + /** + * Count the diff between current scroll position and the bottom of the list. + * Diff == (height of all items in the list) - (height of the layout with the list) - (how far user scrolled) + */ + scrollingVerticalBottomOffset.current = fullContentHeight - layoutMeasurement.height - contentOffset.y; handleUnreadFloatingButton(); }; + const reportHasComments = visibleReportActions.length > 0; + + // Parse Fullstory attributes on initial render + useLayoutEffect(parseFSAttributes, []); + return ( diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index b42b4d0bf21e4..54afc159c91c6 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -180,6 +180,7 @@ function MoneyRequestReportTransactionList({report, transactions, reportActions, id={transaction.transactionID} style={[pressableStyle]} onMouseLeave={handleMouseLeave} + key={transaction.transactionID} > void; shouldShowLoadingBar?: boolean; + cancelSearch?: () => void; }; function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, shouldDisplayHelpButton = true, cancelSearch, shouldShowLoadingBar = false}: TopBarProps) { diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 5456843c66704..5aa7cfae71059 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -1,4 +1,6 @@ import {useCallback, useContext} from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {ScrollView} from 'react-native'; import {ActionListContext} from '@pages/home/ReportScreenContext'; import type ReportScrollManagerData from './types'; @@ -41,6 +43,13 @@ function useReportScrollManager(): ReportScrollManagerData { return; } + const scrollViewRef = flatListRef.current.getNativeScrollRef(); + // Try to scroll on underlying scrollView if available, fallback to usual listRef + if (scrollViewRef && 'scrollToEnd' in scrollViewRef) { + (scrollViewRef as ScrollView).scrollToEnd({animated: false}); + return; + } + flatListRef.current.scrollToEnd({animated: false}); }, [flatListRef]); diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index d6aa2f25b8413..e7585e1462460 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -84,30 +84,34 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { if (shouldUseNarrowLayout) { return ( - - - - - + + + + + + + + + ); } @@ -127,6 +131,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index da9f0dadfeaf6..1ad42f612ab0d 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -1,7 +1,5 @@ import type {ListRenderItemInfo} from '@react-native/virtualized-lists/Lists/VirtualizedList'; import {useIsFocused, useRoute} from '@react-navigation/native'; -// eslint-disable-next-line lodash/import-scope -import type {DebouncedFunc} from 'lodash'; import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; @@ -31,11 +29,9 @@ import { isConsecutiveActionMadeByPreviousActor, isConsecutiveChronosAutomaticTimerAction, isDeletedParentAction, - isReportActionUnread, isReportPreviewAction, isReversedTransaction, isTransactionThread, - shouldHideNewMarker, wasMessageReceivedWhileOffline, } from '@libs/ReportActionsUtils'; import { @@ -65,8 +61,7 @@ import FloatingMessageCounter from './FloatingMessageCounter'; import getInitialNumToRender from './getInitialNumReportActionsToRender'; import ListBoundaryLoader from './ListBoundaryLoader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; - -type LoadNewerChats = DebouncedFunc<(params: {distanceFromStart: number}) => void>; +import shouldDisplayNewMarkerOnReportAction from './shouldDisplayNewMarkerOnReportAction'; type ReportActionsListProps = { /** The report currently being looked at */ @@ -237,62 +232,30 @@ function ReportActionsList({ * The reportActionID the unread marker should display above */ const unreadMarkerReportActionID = useMemo(() => { - const shouldDisplayNewMarker = (message: OnyxTypes.ReportAction, index: number): boolean => { - const nextMessage = sortedVisibleReportActions.at(index + 1); - const isNextMessageUnread = !!nextMessage && isReportActionUnread(nextMessage, unreadMarkerTime); - - // If the current message is the earliest message received while offline, we want to display the unread marker above this message. - const isEarliestReceivedOfflineMessage = index === earliestReceivedOfflineMessageIndex; - if (isEarliestReceivedOfflineMessage && !isNextMessageUnread) { - return true; - } - - // If the unread marker should be hidden or is not within the visible area, don't show the unread marker. - if (shouldHideNewMarker(message)) { - return false; - } - - const isCurrentMessageUnread = isReportActionUnread(message, unreadMarkerTime); - - // If the current message is read or the next message is unread, don't show the unread marker. - if (!isCurrentMessageUnread || isNextMessageUnread) { - return false; - } - - const isPendingAdd = (action: OnyxTypes.ReportAction) => { - return action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; - }; - - // If no unread marker exists, don't set an unread marker for newly added messages from the current user. - const isFromCurrentUser = accountID === (isReportPreviewAction(message) ? message.childLastActorAccountID : message.actorAccountID); - const isNewMessage = !prevSortedVisibleReportActionsObjects[message.reportActionID]; - - // The unread marker will show if the action's `created` time is later than `unreadMarkerTime`. - // The `unreadMarkerTime` has already been updated to match the optimistic action created time, - // but once the new action is saved on the backend, the actual created time will be later than the optimistic one. - // Therefore, we also need to prevent the unread marker from appearing for previously optimistic actions. - const isPreviouslyOptimistic = - (isPendingAdd(prevSortedVisibleReportActionsObjects[message.reportActionID]) && !isPendingAdd(message)) || - (!!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction); - const shouldIgnoreUnreadForCurrentUserMessage = !prevUnreadMarkerReportActionID.current && isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic); - - if (isFromCurrentUser) { - return !shouldIgnoreUnreadForCurrentUserMessage; - } - - return !isNewMessage || scrollingVerticalOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; - }; - - // If there are message that were recevied while offline, - // we can skip checking all messages later than the earliest recevied offline message. + // If there are message that were received while offline, + // we can skip checking all messages later than the earliest received offline message. const startIndex = earliestReceivedOfflineMessageIndex ?? 0; // Scan through each visible report action until we find the appropriate action to show the unread marker for (let index = startIndex; index < sortedVisibleReportActions.length; index++) { const reportAction = sortedVisibleReportActions.at(index); + const nextAction = sortedVisibleReportActions.at(index + 1); + const isEarliestReceivedOfflineMessage = index === earliestReceivedOfflineMessageIndex; // eslint-disable-next-line react-compiler/react-compiler - if (reportAction && shouldDisplayNewMarker(reportAction, index)) { + const shouldDisplayNewMarker = + reportAction && + shouldDisplayNewMarkerOnReportAction({ + message: reportAction, + nextMessage: nextAction, + isEarliestReceivedOfflineMessage, + accountID, + prevSortedVisibleReportActionsObjects, + unreadMarkerTime, + scrollingVerticalOffset: scrollingVerticalOffset.current, + prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, + }); + if (shouldDisplayNewMarker) { return reportAction.reportActionID; } } @@ -774,4 +737,4 @@ ReportActionsList.displayName = 'ReportActionsList'; export default memo(ReportActionsList); -export type {LoadNewerChats, ReportActionsListProps}; +export type {ReportActionsListProps}; diff --git a/src/pages/home/report/shouldDisplayNewMarkerOnReportAction.ts b/src/pages/home/report/shouldDisplayNewMarkerOnReportAction.ts new file mode 100644 index 0000000000000..ae272c1bca420 --- /dev/null +++ b/src/pages/home/report/shouldDisplayNewMarkerOnReportAction.ts @@ -0,0 +1,88 @@ +import {isReportActionUnread, isReportPreviewAction, shouldHideNewMarker} from '@libs/ReportActionsUtils'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +type ShouldDisplayNewMarkerOnReportActionParams = { + /** The reportAction for which the check is done */ + message: OnyxTypes.ReportAction; + + /** The reportAction adjacent to `message` (either previous or next one) */ + nextMessage: OnyxTypes.ReportAction | undefined; + + /** Is it the earliestReceivedOfflineMessage */ + isEarliestReceivedOfflineMessage: boolean; + + /** Time for unreadMarker */ + unreadMarkerTime: string | undefined; + + /** User accountID */ + accountID: number | undefined; + + /** Map of reportActions saved via usePrev */ + prevSortedVisibleReportActionsObjects: Record; + + /** Current value for vertical offset */ + scrollingVerticalOffset: number; + + /** The id of reportAction that was last marked as read */ + prevUnreadMarkerReportActionID: string | null; +}; + +/** + * This function decides whether the given report action (message) should have the new marker indicator displayed + * It's used for the standard "chat" Report and for `MoneyRequestReport` actions lists. + */ +const shouldDisplayNewMarkerOnReportAction = ({ + message, + nextMessage, + isEarliestReceivedOfflineMessage, + unreadMarkerTime, + accountID, + prevSortedVisibleReportActionsObjects, + prevUnreadMarkerReportActionID, + scrollingVerticalOffset, +}: ShouldDisplayNewMarkerOnReportActionParams): boolean => { + const isNextMessageUnread = !!nextMessage && isReportActionUnread(nextMessage, unreadMarkerTime); + + // If the current message is the earliest message received while offline, we want to display the unread marker above this message. + if (isEarliestReceivedOfflineMessage && !isNextMessageUnread) { + return true; + } + + // If the unread marker should be hidden or is not within the visible area, don't show the unread marker. + if (shouldHideNewMarker(message)) { + return false; + } + + const isCurrentMessageUnread = isReportActionUnread(message, unreadMarkerTime); + + // If the current message is read or the next message is unread, don't show the unread marker. + if (!isCurrentMessageUnread || isNextMessageUnread) { + return false; + } + + const isPendingAdd = (action: OnyxTypes.ReportAction) => { + return action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + }; + + // If no unread marker exists, don't set an unread marker for newly added messages from the current user. + const isFromCurrentUser = accountID === (isReportPreviewAction(message) ? message.childLastActorAccountID : message.actorAccountID); + const isNewMessage = !prevSortedVisibleReportActionsObjects[message.reportActionID]; + + // The unread marker will show if the action's `created` time is later than `unreadMarkerTime`. + // The `unreadMarkerTime` has already been updated to match the optimistic action created time, + // but once the new action is saved on the backend, the actual created time will be later than the optimistic one. + // Therefore, we also need to prevent the unread marker from appearing for previously optimistic actions. + const isPreviouslyOptimistic = + (isPendingAdd(prevSortedVisibleReportActionsObjects[message.reportActionID]) && !isPendingAdd(message)) || + (!!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction); + const shouldIgnoreUnreadForCurrentUserMessage = !prevUnreadMarkerReportActionID && isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic); + + if (isFromCurrentUser) { + return !shouldIgnoreUnreadForCurrentUserMessage; + } + + return !isNewMessage || scrollingVerticalOffset >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; +}; + +export default shouldDisplayNewMarkerOnReportAction;