diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index b77d74908e7c..a2c789061a3b 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -299,8 +299,6 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) loadNewerChats(false); }, [loadNewerChats]); - const prevUnreadMarkerReportActionID = useRef(null); - const visibleActionsMap = useMemo(() => { return visibleReportActions.reduce((actionsMap, reportAction) => { Object.assign(actionsMap, { @@ -411,12 +409,10 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) currentUserAccountID, prevSortedVisibleReportActionsObjects: prevVisibleActionsMap, unreadMarkerTime, - scrollingVerticalOffset: scrollingVerticalBottomOffset.current, - prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, + isScrolledOverThreshold: scrollingVerticalBottomOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD, isOffline, isReversed: true, }); - prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ reportID: report?.reportID ?? reportIDFromRoute ?? '', diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts new file mode 100644 index 000000000000..518a21a852ad --- /dev/null +++ b/src/hooks/useMarkAsRead.ts @@ -0,0 +1,159 @@ +import {useIsFocused, useRoute} from '@react-navigation/native'; +import type {RefObject} from 'react'; +import {useEffect, useRef, useState} from 'react'; +import {DeviceEventEmitter} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import DateUtils from '@libs/DateUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import {isCurrentActionUnread, isReportPreviewAction} from '@libs/ReportActionsUtils'; +import {isArchivedNonExpenseReport, isUnread} from '@libs/ReportUtils'; +import Visibility from '@libs/Visibility'; +import type {ReportsSplitNavigatorParamList} from '@navigation/types'; +import {readNewestAction} from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useIsAnonymousUser from './useIsAnonymousUser'; +import useOnyx from './useOnyx'; +import useReportIsArchived from './useReportIsArchived'; + +// useRef gets reset when the reportID changes (the list reuses the same instance per report), +// so we use a module-level variable to track the previous report across re-instantiations. +let prevReportID: string | null = null; + +type UseMarkAsReadParams = { + reportID: string; + report: OnyxEntry; + transactionThreadReport: OnyxEntry; + sortedVisibleReportActions: OnyxTypes.ReportAction[]; + isScrolledToEnd: boolean; + hasNewerActions: boolean; +}; + +type UseMarkAsReadResult = { + readActionSkippedRef: RefObject; +}; + +function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisibleReportActions, isScrolledToEnd, hasNewerActions}: UseMarkAsReadParams): UseMarkAsReadResult { + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const isAnonymousUser = useIsAnonymousUser(); + const route = useRoute>(); + const isFocused = useIsFocused(); + const isReportArchived = useReportIsArchived(reportID); + + const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); + + const [isVisible, setIsVisible] = useState(Visibility.isVisible); + useEffect(() => { + const unsubscribe = Visibility.onVisibilityChange(() => { + setIsVisible(Visibility.isVisible()); + }); + return unsubscribe; + }, []); + + const readActionSkippedRef = useRef(false); + const userActiveSince = useRef(DateUtils.getDBTime()); + const lastMessageTime = useRef(null); + const didMarkReportAsReadInitially = useRef(false); + + const lastAction = sortedVisibleReportActions.at(0); + const isReportUnreadValue = isUnread(report, transactionThreadReport, isReportArchived) || (!!lastAction && isCurrentActionUnread(report, lastAction)); + + useEffect(() => { + userActiveSince.current = DateUtils.getDBTime(); + didMarkReportAsReadInitially.current = false; + prevReportID = reportID; + }, [reportID]); + + useEffect(() => { + if (isAnonymousUser) { + return; + } + + const subscription = DeviceEventEmitter.addListener(`unreadAction_${reportID}`, () => { + userActiveSince.current = DateUtils.getDBTime(); + }); + return () => subscription.remove(); + }, [reportID, isAnonymousUser]); + + useEffect(() => { + if (!isReportUnreadValue || didMarkReportAsReadInitially.current) { + didMarkReportAsReadInitially.current = true; + return; + } + + didMarkReportAsReadInitially.current = true; + readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + }, [isReportUnreadValue, reportID, reportLoadingState?.hasOnceLoadedReportActions]); + + const didMarkOnReportChangeRef = useRef(false); + + useEffect(() => { + didMarkOnReportChangeRef.current = false; + if (reportID !== prevReportID) { + return; + } + + const isLastActionUnread = !!lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions); + if (!isUnread(report, transactionThreadReport, isReportArchived) && !isLastActionUnread) { + return; + } + const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION; + + if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { + readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + if (isFromNotification) { + Navigation.setParams({referrer: undefined}); + } + didMarkOnReportChangeRef.current = true; + return; + } + + readActionSkippedRef.current = true; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [report?.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, reportID, isVisible, reportLoadingState?.hasOnceLoadedReportActions]); + + useEffect(() => { + if (didMarkOnReportChangeRef.current) { + didMarkOnReportChangeRef.current = false; + return; + } + if (reportID !== prevReportID) { + return; + } + + if (!isVisible || !isFocused) { + if (!lastMessageTime.current) { + lastMessageTime.current = lastAction?.created ?? ''; + } + return; + } + + const newMessageTimeReference = lastMessageTime.current && report?.lastReadTime && lastMessageTime.current > report.lastReadTime ? userActiveSince.current : report?.lastReadTime; + lastMessageTime.current = null; + + const isArchivedReport = isArchivedNonExpenseReport(report, isReportArchived); + const hasNewMessagesInView = isScrolledToEnd; + const hasUnreadReportAction = sortedVisibleReportActions.some( + (reportAction) => + newMessageTimeReference && + newMessageTimeReference < reportAction.created && + (isReportPreviewAction(reportAction) ? reportAction.childLastActorAccountID : reportAction.actorAccountID) !== currentUserAccountID, + ); + + if (!isArchivedReport && (!hasNewMessagesInView || !hasUnreadReportAction)) { + return; + } + + readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + userActiveSince.current = DateUtils.getDBTime(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVisible, isFocused, reportLoadingState?.hasOnceLoadedReportActions]); + + return {readActionSkippedRef}; +} + +export default useMarkAsRead; diff --git a/src/hooks/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts new file mode 100644 index 000000000000..a6172bd43e6c --- /dev/null +++ b/src/hooks/useUnreadMarker.ts @@ -0,0 +1,133 @@ +import {useEffect, useState} from 'react'; +import {DeviceEventEmitter} from 'react-native'; +import {wasMessageReceivedWhileOffline} from '@libs/ReportActionsUtils'; +import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useIsAnonymousUser from './useIsAnonymousUser'; +import useLocalize from './useLocalize'; +import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; +import useOnyx from './useOnyx'; +import usePrevious from './usePrevious'; + +type UseUnreadMarkerParams = { + reportID: string; + sortedVisibleReportActions: OnyxTypes.ReportAction[]; + sortedReportActions: OnyxTypes.ReportAction[]; + oldestUnreadReportActionID: string | undefined; + isScrolledOverThreshold: boolean; + hasOnceLoadedReportActions: boolean; +}; + +type UseUnreadMarkerResult = { + unreadMarkerReportActionID: string | null; + unreadMarkerReportActionIndex: number; +}; + +const lastReadTimeSelector = (report: OnyxTypes.Report | undefined) => report?.lastReadTime ?? ''; + +function useUnreadMarker({ + reportID, + sortedVisibleReportActions, + sortedReportActions, + oldestUnreadReportActionID, + isScrolledOverThreshold, + hasOnceLoadedReportActions, +}: UseUnreadMarkerParams): UseUnreadMarkerResult { + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const isAnonymousUser = useIsAnonymousUser(); + const {getLocalDateFromDatetime} = useLocalize(); + const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); + + const [reportLastReadTimeValue] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + selector: lastReadTimeSelector, + }); + const reportLastReadTime = reportLastReadTimeValue ?? ''; + + const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime); + + const [trackedReportID, setTrackedReportID] = useState(reportID); + if (trackedReportID !== reportID) { + setTrackedReportID(reportID); + setUnreadMarkerTime(reportLastReadTime); + } + + if (unreadMarkerTime === '' && reportLastReadTime !== '') { + setUnreadMarkerTime(reportLastReadTime); + } + + useEffect(() => { + if (isAnonymousUser) { + return; + } + + const unreadActionSubscription = DeviceEventEmitter.addListener(`unreadAction_${reportID}`, (newLastReadTime: string) => { + setUnreadMarkerTime(newLastReadTime); + }); + const readNewestActionSubscription = DeviceEventEmitter.addListener(`readNewestAction_${reportID}`, (newLastReadTime: string) => { + setUnreadMarkerTime(newLastReadTime); + }); + + return () => { + unreadActionSubscription.remove(); + readNewestActionSubscription.remove(); + }; + }, [reportID, isAnonymousUser]); + + const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = sortedVisibleReportActions.reduce((actions, action) => { + // eslint-disable-next-line no-param-reassign + actions[action.reportActionID] = action; + return actions; + }, {}); + const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); + + let earliestReceivedOfflineMessageIndex: number | undefined; + for (let i = sortedReportActions.length - 1; i >= 0; i--) { + const message = sortedReportActions.at(i); + if (message && wasMessageReceivedWhileOffline(message, isOffline, lastOfflineAt.current, lastOnlineAt.current, getLocalDateFromDatetime)) { + earliestReceivedOfflineMessageIndex = i; + break; + } + } + + // Index must be in the same domain as FlatList `data` (sortedVisibleReportActions), not the paginated full chain. + let oldestUnreadReportActionMarker: [string, number] | undefined; + if (oldestUnreadReportActionID && !hasOnceLoadedReportActions) { + const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportActionID); + if (visibleIndex >= 0) { + oldestUnreadReportActionMarker = [oldestUnreadReportActionID, visibleIndex]; + } + } + + const scanned = getUnreadMarkerReportAction({ + visibleReportActions: sortedVisibleReportActions, + earliestReceivedOfflineMessageIndex, + currentUserAccountID, + prevSortedVisibleReportActionsObjects, + unreadMarkerTime, + isScrolledOverThreshold, + isOffline, + isReversed: false, + isAnonymousUser, + }); + // Pagination is anchored to the oldest unread on first open; that anchor does not change when the user + // marks read or unread, or when messages are deleted. Prefer the scan when it does not match that stale id. + const [unreadMarkerReportActionID, unreadMarkerReportActionIndex]: [string | null, number] = + oldestUnreadReportActionMarker && (scanned[0] === null || scanned[0] === oldestUnreadReportActionMarker[0]) ? oldestUnreadReportActionMarker : scanned; + + // When the user reads a new message as it is received, push unreadMarkerTime down to the + // latest action's timestamp so new incoming actions display over those new messages instead of + // sticking to the initial lastReadTime. + const mostRecentReportActionCreated = sortedVisibleReportActions.at(0)?.created ?? ''; + if (!isAnonymousUser && !unreadMarkerReportActionID && mostRecentReportActionCreated > unreadMarkerTime) { + setUnreadMarkerTime(mostRecentReportActionCreated); + } + + return { + unreadMarkerReportActionID, + unreadMarkerReportActionIndex, + }; +} + +export default useUnreadMarker; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 0fdbf5a1ae95..9d08ddf8cccc 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,18 +1,17 @@ -import {useIsFocused, useRoute} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; import type {ListRenderItemInfo} from '@shopify/flash-list'; -import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; // eslint-disable-next-line no-restricted-imports -import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import InvertedFlashList from '@components/FlashList/InvertedFlashList'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatListScrollKey'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useEnvironment from '@hooks/useEnvironment'; -import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useLocalize from '@hooks/useLocalize'; +import useMarkAsRead from '@hooks/useMarkAsRead'; import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; @@ -21,10 +20,10 @@ import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; import useThemeStyles from '@hooks/useThemeStyles'; +import useUnreadMarker from '@hooks/useUnreadMarker'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {isSafari} from '@libs/Browser'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; -import DateUtils from '@libs/DateUtils'; import durationHighlightItem from '@libs/Navigation/helpers/getDurationHighlightItem'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -33,14 +32,12 @@ import { getFirstVisibleReportActionID, getReportActionMessage, isConsecutiveActionMadeByPreviousActor, - isCurrentActionUnread, isDeletedParentAction, isNewerReportAction, isReportPreviewAction, isReversedTransaction, isSentMoneyReportAction, isTransactionThread, - wasMessageReceivedWhileOffline, } from '@libs/ReportActionsUtils'; import { chatIncludesChronosWithID, @@ -53,9 +50,7 @@ import { isIOUReport, isMoneyRequestReport, isTaskReport, - isUnread, } from '@libs/ReportUtils'; -import Visibility from '@libs/Visibility'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; @@ -71,7 +66,6 @@ import ReportActionIndexContext from './ReportActionIndexContext'; import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import ReportActionsListPaddingView from './ReportActionsListPaddingView'; -import {getUnreadMarkerReportAction} from './shouldDisplayNewMarkerOnReportAction'; import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; import useReportActionsNewActionLiveTail from './useReportActionsNewActionLiveTail'; import useReportUnreadMessageScrollTracking from './useReportUnreadMessageScrollTracking'; @@ -137,10 +131,6 @@ type ReportActionsListProps = { onShowPreviousMessages?: () => void; }; -// Seems that there is an architecture issue that prevents us from using the reportID with useRef -// the useRef value gets reset when the reportID changes, so we use a global variable to keep track -let prevReportID: string | null = null; - /** * Create a unique key for each action in the FlatList. * We use the reportActionID that is a string representation of a random 64-bit int, which should be @@ -177,26 +167,19 @@ function ReportActionsList({ hasPreviousMessages, onShowPreviousMessages, }: ReportActionsListProps) { - const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isProduction} = useEnvironment(); - const {getLocalDateFromDatetime} = useLocalize(); - const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); + const {isOffline} = useNetworkWithOfflineStatus(); const route = useRoute>(); const reportScrollManager = useReportScrollManager(); const {scrollOffsetRef} = useContext(ActionListContext); const {draftReportAction, hasActiveDraft, isDraftPendingCompletion} = useConciergeDraft(); const {clearDraft} = useConciergeDraftActions(); - const userActiveSince = useRef(DateUtils.getDBTime()); - const lastMessageTime = useRef(null); - const [isVisible, setIsVisible] = useState(Visibility.isVisible); - const isFocused = useIsFocused(); - const isAnonymousUser = useIsAnonymousUser(); const isReportArchived = useReportIsArchived(report?.reportID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); @@ -228,15 +211,6 @@ function ReportActionsList({ const backTo = route?.params?.backTo as string; const linkedReportActionID = route?.params?.reportActionID; - useEffect(() => { - const unsubscribe = Visibility.onVisibilityChange(() => { - setIsVisible(Visibility.isVisible()); - }); - - return unsubscribe; - }, []); - - const readActionSkipped = useRef(false); const hasHeaderRendered = useRef(false); const lastAction = sortedVisibleReportActions.at(0); @@ -250,77 +224,25 @@ function ReportActionsList({ ); const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); - const reportLastReadTime = report.lastReadTime ?? ''; + const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => scrollOffsetRef.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - /** - * The index of the earliest message that was received while offline - */ - const earliestReceivedOfflineMessageIndex = useMemo(() => { - // Create a list of (sorted) indices of message that were received while offline - const receivedOfflineMessages = sortedReportActions.reduce((acc, message, index) => { - if (wasMessageReceivedWhileOffline(message, isOffline, lastOfflineAt.current, lastOnlineAt.current, getLocalDateFromDatetime)) { - acc[index] = index; - } - - return acc; - }, []); - - // The last index in the list is the earliest message that was received while offline - return receivedOfflineMessages.at(-1); - }, [getLocalDateFromDatetime, isOffline, lastOfflineAt, lastOnlineAt, sortedReportActions]); - - // Index must be in the same domain as FlatList `data` (sortedVisibleReportActions), not the paginated full chain. - const oldestUnreadReportActionMarker = useMemo<[string, number] | undefined>(() => { - if (!oldestUnreadReportAction || reportLoadingState?.hasOnceLoadedReportActions) { - return undefined; - } - const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportAction.reportActionID); - if (visibleIndex < 0) { - return undefined; - } - return [oldestUnreadReportAction.reportActionID, visibleIndex]; - }, [oldestUnreadReportAction, reportLoadingState?.hasOnceLoadedReportActions, sortedVisibleReportActions]); + const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useUnreadMarker({ + reportID: report.reportID, + sortedVisibleReportActions, + sortedReportActions, + oldestUnreadReportActionID: oldestUnreadReportAction?.reportActionID, + isScrolledOverThreshold: hasScrolledOverThreshold, + hasOnceLoadedReportActions: !!reportLoadingState?.hasOnceLoadedReportActions, + }); - /** - * The reportActionID the unread marker should display above - */ - const prevUnreadMarkerReportActionID = useRef(null); - const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime); - const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = useMemo(() => { - // eslint-disable-next-line react-hooks/refs - const scanned = getUnreadMarkerReportAction({ - visibleReportActions: sortedVisibleReportActions, - earliestReceivedOfflineMessageIndex, - currentUserAccountID, - prevSortedVisibleReportActionsObjects, - unreadMarkerTime, - scrollingVerticalOffset: scrollOffsetRef.current, - prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, - isOffline, - isReversed: false, - isAnonymousUser, - }); - if (oldestUnreadReportActionMarker) { - const [oldestAnchorActionID] = oldestUnreadReportActionMarker; - // Pagination is anchored to the oldest unread on first open; that anchor does not change when the user - // marks read or unread, or when messages are deleted. Prefer the scan when it does not match that stale id. - if (scanned[0] !== null && scanned[0] !== oldestAnchorActionID) { - return scanned; - } - } - return oldestUnreadReportActionMarker ?? scanned; - }, [ - currentUserAccountID, - earliestReceivedOfflineMessageIndex, - isAnonymousUser, - isOffline, - oldestUnreadReportActionMarker, - prevSortedVisibleReportActionsObjects, - scrollOffsetRef, + const {readActionSkippedRef} = useMarkAsRead({ + reportID: report.reportID, + report, + transactionThreadReport, sortedVisibleReportActions, - unreadMarkerTime, - ]); - prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; + isScrolledToEnd: !hasScrolledOverThreshold, + hasNewerActions, + }); const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); @@ -371,34 +293,8 @@ function ReportActionsList({ const draftAutoScrollKey = isSyntheticDraftVisible ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; const previousDraftAutoScrollKey = usePrevious(draftAutoScrollKey); - const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); const shouldMaintainVisibleContentPosition = hasScrolledOverThreshold || shouldFocusToTopOnMount; - /** - * The timestamp for the unread marker. - * - * This should ONLY be updated when the user - * - switches reports - * - marks a message as read/unread - * - reads a new message as it is received - */ - useEffect(() => { - setUnreadMarkerTime(reportLastReadTime); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [report.reportID]); - - // When lastReadTime transitions from empty to a real value (e.g., data hasn't - // loaded yet after sign-in), update the marker so it uses the fresh value - // instead of the empty string from initial mount. - useEffect(() => { - if (reportLastReadTime === '' || unreadMarkerTime !== '') { - return; - } - setUnreadMarkerTime(reportLastReadTime); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportLastReadTime]); - useEffect(() => { if (!draftReportAction || isSyntheticDraftVisible) { return; @@ -407,49 +303,6 @@ function ReportActionsList({ clearDraft(); }, [clearDraft, draftReportAction, isSyntheticDraftVisible]); - /** - * Subscribe to read/unread events and update our unreadMarkerTime - */ - useEffect(() => { - if (isAnonymousUser) { - return; - } - - 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 () => { - unreadActionSubscription.remove(); - readNewestActionSubscription.remove(); - }; - }, [report.reportID, isAnonymousUser]); - - /** - * 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 (isAnonymousUser || unreadMarkerReportActionID) { - return; - } - - const mostRecentReportActionCreated = lastAction?.created ?? ''; - if (mostRecentReportActionCreated <= unreadMarkerTime) { - return; - } - - setUnreadMarkerTime(mostRecentReportActionCreated); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastAction?.created]); - const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated || isReportPreviewAction(lastAction); @@ -466,14 +319,14 @@ function ReportActionsList({ useReportUnreadMessageScrollTracking({ reportID: report.reportID, currentVerticalScrollingOffsetRef: scrollOffsetRef, - readActionSkippedRef: readActionSkipped, + readActionSkippedRef, hasNewerActions, unreadMarkerReportActionIndex, isInverted: true, onTrackScrolling: (event: NativeSyntheticEvent) => { const offset = event.nativeEvent.contentOffset.y; scrollOffsetRef.current = offset; - setHasScrolledOverThreshold(offset > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + setHasScrolledOverThreshold(offset >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); onScroll?.(event); }, hasOnceLoadedReportActions: !!reportLoadingState?.hasOnceLoadedReportActions, @@ -528,123 +381,11 @@ function ReportActionsList({ }); }, [draftAutoScrollKey, hasNewestReportAction, previousDraftAutoScrollKey, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible]); - useEffect(() => { - userActiveSince.current = DateUtils.getDBTime(); - prevReportID = report.reportID; - }, [report.reportID]); - // Same-screen report switches reuse this instance; per-report one-shot flags must not leak across reports. useEffect(() => { hasHeaderRendered.current = false; }, [report.reportID]); - const isReportUnread = useMemo( - () => isUnread(report, transactionThreadReport, isReportArchived) || (lastAction && isCurrentActionUnread(report, lastAction)), - [report, transactionThreadReport, isReportArchived, lastAction], - ); - - // Mark the report as read when the user initially opens the report and there are unread messages - const didMarkReportAsReadInitially = useRef(false); - - useEffect(() => { - didMarkReportAsReadInitially.current = false; - }, [report.reportID]); - - useEffect(() => { - if (!isReportUnread || didMarkReportAsReadInitially.current) { - didMarkReportAsReadInitially.current = true; - return; - } - - didMarkReportAsReadInitially.current = true; - readNewestAction(report.reportID, !!reportLoadingState?.hasOnceLoadedReportActions); - }, [isReportUnread, report.reportID, reportLoadingState?.hasOnceLoadedReportActions]); - - const handleReportChangeMarkAsRead = useCallback(() => { - if (report.reportID !== prevReportID) { - return; - } - - const isLastActionUnread = lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions); - if (!isUnread(report, transactionThreadReport, isReportArchived) && !isLastActionUnread) { - return; - } - // On desktop, when the notification center is displayed, isVisible will return false. - // Currently, there's no programmatic way to dismiss the notification center panel. - // To handle this, we use the 'referrer' parameter to check if the current navigation is triggered from a notification. - const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION; - const isScrolledToEnd = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; - - if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { - readNewestAction(report.reportID, !!reportLoadingState?.hasOnceLoadedReportActions); - if (isFromNotification) { - Navigation.setParams({referrer: undefined}); - } - return true; - } - - readActionSkipped.current = true; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [report.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, report.reportID, isVisible, reportLoadingState?.hasOnceLoadedReportActions]); - - const handleAppVisibilityMarkAsRead = useCallback(() => { - if (report.reportID !== prevReportID) { - return; - } - - if (!isVisible || !isFocused) { - if (!lastMessageTime.current) { - lastMessageTime.current = lastAction?.created ?? ''; - } - return; - } - - // In case the user read new messages (after being inactive) with other device we should - // show marker based on report.lastReadTime - const newMessageTimeReference = lastMessageTime.current && report.lastReadTime && lastMessageTime.current > report.lastReadTime ? userActiveSince.current : report.lastReadTime; - lastMessageTime.current = null; - - const isArchivedReport = isArchivedNonExpenseReport(report, isReportArchived); - const hasNewMessagesInView = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; - const hasUnreadReportAction = sortedVisibleReportActions.some( - (reportAction) => - newMessageTimeReference && - newMessageTimeReference < reportAction.created && - (isReportPreviewAction(reportAction) ? reportAction.childLastActorAccountID : reportAction.actorAccountID) !== currentUserAccountID, - ); - - if (!isArchivedReport && (!hasNewMessagesInView || !hasUnreadReportAction)) { - return; - } - - readNewestAction(report.reportID, !!reportLoadingState?.hasOnceLoadedReportActions); - userActiveSince.current = DateUtils.getDBTime(); - return true; - - // This effect logic to `mark as read` will only run when the report focused has new messages and the App visibility - // is changed to visible(meaning user switched to app/web, while user was previously using different tab or application). - // We will mark the report as read in the above case which marks the LHN report item as read while showing the new message - // marker for the chat messages received while the user wasn't focused on the report or on another browser tab for web. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isFocused, isVisible, reportLoadingState?.hasOnceLoadedReportActions]); - - const prevHandleReportChangeMarkAsRead = useRef<() => void>(null); - const prevHandleAppVisibilityMarkAsRead = useRef<() => void>(null); - - useEffect(() => { - let isMarkedAsRead = false; - if (handleReportChangeMarkAsRead !== prevHandleReportChangeMarkAsRead.current) { - isMarkedAsRead = !!handleReportChangeMarkAsRead(); - } - - if (!isMarkedAsRead && handleAppVisibilityMarkAsRead !== prevHandleAppVisibilityMarkAsRead.current) { - handleAppVisibilityMarkAsRead(); - } - - prevHandleReportChangeMarkAsRead.current = handleReportChangeMarkAsRead; - prevHandleAppVisibilityMarkAsRead.current = handleAppVisibilityMarkAsRead; - }, [handleReportChangeMarkAsRead, handleAppVisibilityMarkAsRead]); - useEffect(() => { if (initialScrollKey) { return; @@ -711,9 +452,19 @@ function ReportActionsList({ return; } reportScrollManager.scrollToBottom(); - readActionSkipped.current = false; + readActionSkippedRef.current = false; readNewestAction(report.reportID, !!reportLoadingState?.hasOnceLoadedReportActions); - }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, report.reportID, backTo, introSelected, reportLoadingState?.hasOnceLoadedReportActions, betas]); + }, [ + setIsFloatingMessageCounterVisible, + hasNewestReportAction, + reportScrollManager, + report.reportID, + backTo, + introSelected, + reportLoadingState?.hasOnceLoadedReportActions, + betas, + readActionSkippedRef, + ]); const scrollToActionBadgeTarget = useCallback(() => { if (actionBadgeTargetIndex < 0) { diff --git a/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts b/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts index 05ac1e9f782c..7f9106ced655 100644 --- a/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts +++ b/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts @@ -21,11 +21,8 @@ type ShouldDisplayNewMarkerOnReportActionParams = { /** 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; + /** Whether the list is scrolled past the threshold where incoming actions are considered out of view */ + isScrolledOverThreshold: boolean; /** Whether the network is offline */ isOffline: boolean; @@ -42,8 +39,7 @@ const shouldDisplayNewMarkerOnReportAction = ({ unreadMarkerTime, currentUserAccountID, prevSortedVisibleReportActionsObjects, - prevUnreadMarkerReportActionID, - scrollingVerticalOffset, + isScrolledOverThreshold, isOffline, }: ShouldDisplayNewMarkerOnReportActionParams): boolean => { const isNextMessageUnread = !!nextMessage && isReportActionUnread(nextMessage, unreadMarkerTime); @@ -80,13 +76,13 @@ const shouldDisplayNewMarkerOnReportAction = ({ const isPreviouslyOptimistic = (isPendingAdd(prevSortedVisibleReportActionsObjects[message.reportActionID]) && !isPendingAdd(message)) || (!!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction); - const shouldIgnoreUnreadForCurrentUserMessage = !prevUnreadMarkerReportActionID && isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic); + const shouldIgnoreUnreadForCurrentUserMessage = isNewMessage || isPreviouslyOptimistic; if (isFromCurrentUser) { return !shouldIgnoreUnreadForCurrentUserMessage; } - return !isNewMessage || scrollingVerticalOffset >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + return !isNewMessage || isScrolledOverThreshold; }; export default shouldDisplayNewMarkerOnReportAction; @@ -107,11 +103,8 @@ type GetUnreadMarkerReportActionParams = { /** Time for unreadMarker */ unreadMarkerTime: string | undefined; - /** Current value for vertical offset */ - scrollingVerticalOffset: number; - - /** The id of reportAction that was last marked as read */ - prevUnreadMarkerReportActionID: string | null; + /** Whether the list is scrolled past the threshold where incoming actions are considered out of view */ + isScrolledOverThreshold: boolean; /** Whether the network is offline */ isOffline: boolean; @@ -133,8 +126,7 @@ const getUnreadMarkerReportAction = ({ currentUserAccountID, prevSortedVisibleReportActionsObjects, unreadMarkerTime, - scrollingVerticalOffset, - prevUnreadMarkerReportActionID, + isScrolledOverThreshold, isOffline, isReversed, isAnonymousUser = false, @@ -175,8 +167,7 @@ const getUnreadMarkerReportAction = ({ currentUserAccountID, prevSortedVisibleReportActionsObjects, unreadMarkerTime, - scrollingVerticalOffset, - prevUnreadMarkerReportActionID, + isScrolledOverThreshold, isOffline, }); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index ae54db276d3c..a2d13ac76ada 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -54,7 +54,7 @@ import { } from '../../src/libs/ReportActionsUtils'; import {buildOptimisticCreatedReportForUnapprovedAction} from '../../src/libs/ReportUtils'; import ONYXKEYS from '../../src/ONYXKEYS'; -import shouldDisplayNewMarkerOnReportAction from '../../src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; +import shouldDisplayNewMarkerOnReportAction, {getUnreadMarkerReportAction} from '../../src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; import type {Card, DecisionName, OriginalMessageIOU, PersonalDetailsList, Report, ReportAction, ReportActions} from '../../src/types/onyx'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; @@ -4973,8 +4973,7 @@ describe('ReportActionsUtils', () => { unreadMarkerTime, currentUserAccountID, prevSortedVisibleReportActionsObjects: {}, - scrollingVerticalOffset: CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD + 1, - prevUnreadMarkerReportActionID: 'some-id', + isScrolledOverThreshold: true, }; it('returns true when isEarliestReceivedOfflineMessage is true and next message is not unread', () => { @@ -5035,18 +5034,179 @@ describe('ReportActionsUtils', () => { ).toBe(false); }); - it('returns false when message is from current user, is new, and no prevUnreadMarkerReportActionID', () => { + it('returns false when message is from current user and is new', () => { const message = makeAction({actorAccountID: currentUserAccountID, reportActionID: 'new-action-id'}); expect( shouldDisplayNewMarkerOnReportAction({ ...baseParams, message, - prevUnreadMarkerReportActionID: null, prevSortedVisibleReportActionsObjects: {}, isOffline: false, }), ).toBe(false); }); + + it('returns false when message is from current user and was previously optimistic (now confirmed)', () => { + const message = makeAction({actorAccountID: currentUserAccountID, reportActionID: 'confirmed-action-id', pendingAction: null}); + const prevSortedVisibleReportActionsObjects = { + [message.reportActionID]: makeAction({ + actorAccountID: currentUserAccountID, + reportActionID: 'confirmed-action-id', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }), + }; + expect( + shouldDisplayNewMarkerOnReportAction({ + ...baseParams, + message, + prevSortedVisibleReportActionsObjects, + isOffline: false, + }), + ).toBe(false); + }); + + it('returns true when message is from current user but is already present (not new, not optimistic)', () => { + const message = makeAction({actorAccountID: currentUserAccountID, reportActionID: 'existing-action-id'}); + const prevSortedVisibleReportActionsObjects = { + [message.reportActionID]: makeAction({actorAccountID: currentUserAccountID, reportActionID: 'existing-action-id'}), + }; + expect( + shouldDisplayNewMarkerOnReportAction({ + ...baseParams, + message, + prevSortedVisibleReportActionsObjects, + isOffline: false, + }), + ).toBe(true); + }); + + it('returns true when an unread message from another user is new and the list is scrolled over the threshold', () => { + const message = makeAction({reportActionID: 'other-new-id'}); + expect( + shouldDisplayNewMarkerOnReportAction({ + ...baseParams, + message, + prevSortedVisibleReportActionsObjects: {}, + isScrolledOverThreshold: true, + isOffline: false, + }), + ).toBe(true); + }); + + it('returns false when an unread message from another user is new and the list is within the threshold', () => { + const message = makeAction({reportActionID: 'other-new-id'}); + expect( + shouldDisplayNewMarkerOnReportAction({ + ...baseParams, + message, + prevSortedVisibleReportActionsObjects: {}, + isScrolledOverThreshold: false, + isOffline: false, + }), + ).toBe(false); + }); + + it('returns true for an already-present unread message from another user regardless of scroll threshold', () => { + const message = makeAction({reportActionID: 'other-existing-id'}); + const prevSortedVisibleReportActionsObjects = { + [message.reportActionID]: makeAction({reportActionID: 'other-existing-id'}), + }; + expect( + shouldDisplayNewMarkerOnReportAction({ + ...baseParams, + message, + prevSortedVisibleReportActionsObjects, + isScrolledOverThreshold: false, + isOffline: false, + }), + ).toBe(true); + }); + }); + + describe('getUnreadMarkerReportAction', () => { + const unreadMarkerTime = '2023-01-01 10:00:00.000'; + const currentUserAccountID = 1; + + function makeAction(overrides: Partial = {}): ReportAction { + return getFakeReportAction(2, { + actorAccountID: 99, + created: '2023-01-01 11:00:00.000', + ...overrides, + }); + } + + const baseScanParams = { + earliestReceivedOfflineMessageIndex: undefined, + currentUserAccountID, + prevSortedVisibleReportActionsObjects: {}, + unreadMarkerTime, + isScrolledOverThreshold: true, + isOffline: false, + isReversed: false, + }; + + it('short-circuits to [null, -1] for an anonymous user', () => { + const visibleReportActions = [makeAction({reportActionID: 'a'})]; + expect( + getUnreadMarkerReportAction({ + ...baseScanParams, + visibleReportActions, + isAnonymousUser: true, + }), + ).toEqual([null, -1]); + }); + + it('returns [null, -1] when no action qualifies (all read)', () => { + const visibleReportActions = [makeAction({reportActionID: 'a', created: '2023-01-01 09:00:00.000'}), makeAction({reportActionID: 'b', created: '2023-01-01 08:00:00.000'})]; + expect( + getUnreadMarkerReportAction({ + ...baseScanParams, + visibleReportActions, + }), + ).toEqual([null, -1]); + }); + + it('returns the [reportActionID, index] of the first qualifying unread action in a forward scan', () => { + const visibleReportActions = [makeAction({reportActionID: 'newest'}), makeAction({reportActionID: 'older', created: '2023-01-01 09:00:00.000'})]; + expect( + getUnreadMarkerReportAction({ + ...baseScanParams, + visibleReportActions, + }), + ).toEqual(['newest', 0]); + }); + + it('skips the concierge greeting action during a forward scan', () => { + const visibleReportActions = [makeAction({reportActionID: CONST.CONCIERGE_GREETING_ACTION_ID}), makeAction({reportActionID: 'real-unread'})]; + expect( + getUnreadMarkerReportAction({ + ...baseScanParams, + visibleReportActions, + }), + ).toEqual(['real-unread', 1]); + }); + + it('starts the forward scan at earliestReceivedOfflineMessageIndex', () => { + const visibleReportActions = [makeAction({reportActionID: 'before-offline'}), makeAction({reportActionID: 'offline-anchor'})]; + expect( + getUnreadMarkerReportAction({ + ...baseScanParams, + visibleReportActions, + earliestReceivedOfflineMessageIndex: 1, + }), + ).toEqual(['offline-anchor', 1]); + }); + + it('scans from high index to low when isReversed is true', () => { + const visibleReportActions = [makeAction({reportActionID: 'read-older', created: '2023-01-01 09:00:00.000'}), makeAction({reportActionID: 'unread-newer'})]; + expect( + getUnreadMarkerReportAction({ + ...baseScanParams, + visibleReportActions, + isReversed: true, + }), + ).toEqual(['unread-newer', 1]); + }); }); describe('getIntegrationSyncFailedMessage', () => { diff --git a/tests/unit/useMarkAsReadTest.ts b/tests/unit/useMarkAsReadTest.ts new file mode 100644 index 000000000000..f40fa56bee9a --- /dev/null +++ b/tests/unit/useMarkAsReadTest.ts @@ -0,0 +1,112 @@ +import {renderHook} from '@testing-library/react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import useMarkAsRead from '@hooks/useMarkAsRead'; +import type Navigation from '@libs/Navigation/Navigation'; +import type * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +const REPORT_ID = '1'; + +let mockIsUnread = true; +let mockIsVisible = true; +let mockIsFocused = true; +let mockReferrer: string | undefined; + +jest.mock('@libs/Visibility', () => ({ + __esModule: true, + default: { + isVisible: () => mockIsVisible, + onVisibilityChange: () => () => {}, + }, +})); + +jest.mock('@libs/ReportUtils', () => { + const actual = jest.requireActual('@libs/ReportUtils'); + return { + ...actual, + isUnread: () => mockIsUnread, + }; +}); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + setParams: jest.fn(), + }, +})); + +jest.mock('@userActions/Report', () => ({ + readNewestAction: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useIsFocused: () => mockIsFocused, + useRoute: () => ({params: {referrer: mockReferrer}}), + }; +}); + +const {readNewestAction} = jest.requireMock<{readNewestAction: jest.Mock}>('@userActions/Report'); +const NavigationMock = jest.requireMock<{default: {setParams: jest.Mock}}>('@libs/Navigation/Navigation').default; + +const REPORT = { + reportID: REPORT_ID, + lastReadTime: '2023-01-01 10:00:00.000', + lastVisibleActionCreated: '2023-01-01 11:00:00.000', +} as OnyxTypes.Report; + +function renderMarkAsRead(params: Partial[0]> = {}) { + return renderHook(() => + useMarkAsRead({ + reportID: REPORT_ID, + report: REPORT as OnyxEntry, + transactionThreadReport: undefined, + sortedVisibleReportActions: [], + isScrolledToEnd: true, + hasNewerActions: false, + ...params, + }), + ); +} + +describe('useMarkAsRead', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsUnread = true; + mockIsVisible = true; + mockIsFocused = true; + mockReferrer = undefined; + }); + + it('marks the report as read on mount when it is unread, visible, and scrolled to the end', () => { + renderMarkAsRead({isScrolledToEnd: true}); + + expect(readNewestAction).toHaveBeenCalledWith(REPORT_ID, false); + }); + + it('does not mark the report as read when it is already read', () => { + mockIsUnread = false; + renderMarkAsRead(); + + expect(readNewestAction).not.toHaveBeenCalled(); + }); + + it('flags readActionSkippedRef when the report is unread but the list is not scrolled to the end', () => { + const {result} = renderMarkAsRead({isScrolledToEnd: false}); + + expect(result.current.readActionSkippedRef.current).toBe(true); + }); + + it('marks read from a notification even when the app is not visible, and clears the referrer param', () => { + mockIsVisible = false; + mockReferrer = CONST.REFERRER.NOTIFICATION; + + renderMarkAsRead({isScrolledToEnd: true}); + + expect(readNewestAction).toHaveBeenCalledWith(REPORT_ID, false); + expect(NavigationMock.setParams).toHaveBeenCalledWith({referrer: undefined}); + }); +}); diff --git a/tests/unit/useUnreadMarkerTest.ts b/tests/unit/useUnreadMarkerTest.ts new file mode 100644 index 000000000000..ea3adc8bc1fb --- /dev/null +++ b/tests/unit/useUnreadMarkerTest.ts @@ -0,0 +1,110 @@ +import {act, renderHook} from '@testing-library/react-native'; +import {DeviceEventEmitter} from 'react-native'; +import useUnreadMarker from '@hooks/useUnreadMarker'; +import type * as OnyxTypes from '@src/types/onyx'; +import {getFakeReportAction} from '../utils/ReportTestUtils'; + +const REPORT_ID = '1'; +const CURRENT_USER_ACCOUNT_ID = 1; +const OTHER_USER_ACCOUNT_ID = 99; +const LAST_READ_TIME = '2023-01-01 10:00:00.000'; + +let mockIsAnonymousUser = false; +let mockLastReadTime: string = LAST_READ_TIME; + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({accountID: CURRENT_USER_ACCOUNT_ID}), +})); + +jest.mock('@hooks/useIsAnonymousUser', () => ({ + __esModule: true, + default: () => mockIsAnonymousUser, +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => [mockLastReadTime], +})); + +function makeAction(reportActionID: string, overrides: Partial = {}): OnyxTypes.ReportAction { + return getFakeReportAction(OTHER_USER_ACCOUNT_ID, { + reportActionID, + actorAccountID: OTHER_USER_ACCOUNT_ID, + created: '2023-01-01 11:00:00.000', + ...overrides, + }); +} + +function renderUnreadMarker(params: Partial[0]> = {}) { + const actions = params.sortedVisibleReportActions ?? [makeAction('m1')]; + return renderHook(() => + useUnreadMarker({ + reportID: REPORT_ID, + sortedVisibleReportActions: actions, + sortedReportActions: actions, + oldestUnreadReportActionID: undefined, + isScrolledOverThreshold: false, + hasOnceLoadedReportActions: true, + ...params, + }), + ); +} + +describe('useUnreadMarker', () => { + beforeEach(() => { + mockIsAnonymousUser = false; + mockLastReadTime = LAST_READ_TIME; + }); + + it('returns [null, -1] for an anonymous user', () => { + mockIsAnonymousUser = true; + const {result} = renderUnreadMarker(); + + expect(result.current.unreadMarkerReportActionID).toBeNull(); + expect(result.current.unreadMarkerReportActionIndex).toBe(-1); + }); + + it('places the marker on an unread message from another user', () => { + const {result} = renderUnreadMarker({sortedVisibleReportActions: [makeAction('m1')]}); + + expect(result.current.unreadMarkerReportActionID).toBe('m1'); + expect(result.current.unreadMarkerReportActionIndex).toBe(0); + }); + + it('prefers the oldestUnread pagination anchor over the scan on first open when the scan finds nothing', () => { + const readAction = makeAction('a', {created: '2023-01-01 09:00:00.000'}); + const {result} = renderUnreadMarker({ + sortedVisibleReportActions: [readAction], + oldestUnreadReportActionID: 'a', + hasOnceLoadedReportActions: false, + }); + + expect(result.current.unreadMarkerReportActionID).toBe('a'); + expect(result.current.unreadMarkerReportActionIndex).toBe(0); + }); + + it('ignores the oldestUnread anchor once the report actions have loaded', () => { + const readAction = makeAction('a', {created: '2023-01-01 09:00:00.000'}); + const {result} = renderUnreadMarker({ + sortedVisibleReportActions: [readAction], + oldestUnreadReportActionID: 'a', + hasOnceLoadedReportActions: true, + }); + + expect(result.current.unreadMarkerReportActionID).toBeNull(); + expect(result.current.unreadMarkerReportActionIndex).toBe(-1); + }); + + it('clears the marker when an unreadAction event advances the unread marker time past the message', () => { + const {result} = renderUnreadMarker({sortedVisibleReportActions: [makeAction('m1')]}); + expect(result.current.unreadMarkerReportActionID).toBe('m1'); + + act(() => { + DeviceEventEmitter.emit(`unreadAction_${REPORT_ID}`, '2023-01-01 12:00:00.000'); + }); + + expect(result.current.unreadMarkerReportActionID).toBeNull(); + expect(result.current.unreadMarkerReportActionIndex).toBe(-1); + }); +});