From 5d1a700335475b6c2d88b5d601f9adc060227c53 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 20 May 2026 11:46:30 +0200 Subject: [PATCH 01/12] extract useUnreadMarker and useMarkAsRead hooks Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useMarkAsRead.ts | 161 ++++++++++ src/hooks/useUnreadMarker.ts | 176 +++++++++++ src/pages/inbox/report/ReportActionsList.tsx | 305 ++----------------- 3 files changed, 360 insertions(+), 282 deletions(-) create mode 100644 src/hooks/useMarkAsRead.ts create mode 100644 src/hooks/useUnreadMarker.ts diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts new file mode 100644 index 000000000000..c56ce238f735 --- /dev/null +++ b/src/hooks/useMarkAsRead.ts @@ -0,0 +1,161 @@ +import {useIsFocused, useRoute} from '@react-navigation/native'; +import type {RefObject} from 'react'; +import {useEffect, useEffectEvent, useRef, useSyncExternalStore} 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 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[]; + scrollingVerticalOffset: RefObject; + hasNewerActions: boolean; +}; + +type UseMarkAsReadResult = { + readActionSkippedRef: RefObject; +}; + +function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisibleReportActions, scrollingVerticalOffset, hasNewerActions}: UseMarkAsReadParams): UseMarkAsReadResult { + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const route = useRoute>(); + const isFocused = useIsFocused(); + const isReportArchived = useReportIsArchived(reportID); + + const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); + + const isVisible = useSyncExternalStore(Visibility.onVisibilityChange, Visibility.isVisible); + + 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)); + + // Refresh userActiveSince + reset initial-mount gate when the same component instance is reused for another report. + useEffect(() => { + userActiveSince.current = DateUtils.getDBTime(); + didMarkReportAsReadInitially.current = false; + prevReportID = reportID; + }, [reportID]); + + // The unread-marker hook also subscribes to `unreadAction_${reportID}` to update the marker + // time. We subscribe separately so this hook owns the userActiveSince concern. + useEffect(() => { + const subscription = DeviceEventEmitter.addListener(`unreadAction_${reportID}`, () => { + userActiveSince.current = DateUtils.getDBTime(); + }); + return () => subscription.remove(); + }, [reportID]); + + // Mark the report as read when the user initially opens the report and there are unread messages. + useEffect(() => { + if (!isReportUnreadValue || didMarkReportAsReadInitially.current) { + didMarkReportAsReadInitially.current = true; + return; + } + + didMarkReportAsReadInitially.current = true; + readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + }, [isReportUnreadValue, reportID, reportLoadingState?.hasOnceLoadedReportActions]); + + const handleReportChangeMarkAsRead = useEffectEvent(() => { + if (reportID !== prevReportID) { + return false; + } + + const isLastActionUnread = !!lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions); + if (!isUnread(report, transactionThreadReport, isReportArchived) && !isLastActionUnread) { + return false; + } + // 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 = scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + + if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { + readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + if (isFromNotification) { + Navigation.setParams({referrer: undefined}); + } + return true; + } + + readActionSkippedRef.current = true; + return false; + }); + + // This `mark as read` will only run when the focused report has new messages and the App visibility + // changes to visible (user switched to the app/web while previously on another tab/application). + // We mark the report as read so the LHN report item is marked read while the unread marker still + // shows the messages received while the user wasn't focused on the report. + const handleAppVisibilityMarkAsRead = useEffectEvent(() => { + if (reportID !== prevReportID) { + return; + } + + if (!isVisible || !isFocused) { + if (!lastMessageTime.current) { + lastMessageTime.current = lastAction?.created ?? ''; + } + return; + } + + // If the user read new messages (after being inactive) on another device, show the 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 = scrollingVerticalOffset.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(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + userActiveSince.current = DateUtils.getDBTime(); + }); + + useEffect(() => { + // handleReportChangeMarkAsRead short-circuits via its return value to prevent duplicate readNewestAction calls. + const isMarkedAsRead = handleReportChangeMarkAsRead(); + if (!isMarkedAsRead) { + handleAppVisibilityMarkAsRead(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [report?.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, reportID, isVisible, isFocused, reportLoadingState?.hasOnceLoadedReportActions]); + + return {readActionSkippedRef}; +} + +export default useMarkAsRead; +export type {UseMarkAsReadParams, UseMarkAsReadResult}; diff --git a/src/hooks/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts new file mode 100644 index 000000000000..9ac292cda518 --- /dev/null +++ b/src/hooks/useUnreadMarker.ts @@ -0,0 +1,176 @@ +import type {RefObject} from 'react'; +import {useEffect, useEffectEvent, useLayoutEffect, useRef, useState} from 'react'; +import {DeviceEventEmitter} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +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[]; + oldestUnreadReportAction: OnyxEntry; + scrollingVerticalOffset: RefObject; + hasOnceLoadedReportActions: boolean; +}; + +type UseUnreadMarkerResult = { + unreadMarkerReportActionID: string | null; + unreadMarkerReportActionIndex: number; +}; + +const lastReadTimeSelector = (report: OnyxTypes.Report | undefined) => report?.lastReadTime ?? ''; + +function useUnreadMarker({ + reportID, + sortedVisibleReportActions, + sortedReportActions, + oldestUnreadReportAction, + scrollingVerticalOffset, + 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 prevUnreadMarkerReportActionID = useRef(null); + const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime); + const [markerState, setMarkerState] = useState<[string | null, number]>([null, -1]); + + // Reset the marker time when switching between reports — same instance is reused per report. + useEffect(() => { + setUnreadMarkerTime(reportLastReadTime); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [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]); + + /** + * Subscribe to read/unread events and update our unreadMarkerTime. + */ + 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); + + // Reading refs at render-time would bail the compiler. Wrap the marker computation in an + // effect-event so the latest ref values are read at commit time, and store the result in state. + const computeMarker = useEffectEvent(() => { + 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 (oldestUnreadReportAction && !hasOnceLoadedReportActions) { + const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportAction.reportActionID); + if (visibleIndex >= 0) { + oldestUnreadReportActionMarker = [oldestUnreadReportAction.reportActionID, visibleIndex]; + } + } + + const scanned = getUnreadMarkerReportAction({ + visibleReportActions: sortedVisibleReportActions, + earliestReceivedOfflineMessageIndex, + currentUserAccountID, + prevSortedVisibleReportActionsObjects, + unreadMarkerTime, + scrollingVerticalOffset: scrollingVerticalOffset.current ?? 0, + prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, + isOffline, + isReversed: false, + isAnonymousUser, + }); + + let next: [string | null, number]; + 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) { + next = scanned; + } else { + next = oldestUnreadReportActionMarker; + } + } else { + next = scanned; + } + + prevUnreadMarkerReportActionID.current = next[0]; + setMarkerState((prev) => (prev[0] === next[0] && prev[1] === next[1] ? prev : next)); + }); + + useLayoutEffect(() => { + computeMarker(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sortedVisibleReportActions, sortedReportActions, oldestUnreadReportAction, hasOnceLoadedReportActions, isOffline, unreadMarkerTime, currentUserAccountID, isAnonymousUser]); + + const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = markerState; + + // 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. Adjusting state during render (vs. a layout effect) is + // the React-recommended pattern — avoids cascading commits. + // https://react.dev/reference/react/useState#storing-information-from-previous-renders + const lastAction = sortedVisibleReportActions.at(0); + const mostRecentReportActionCreated = lastAction?.created ?? ''; + if (!isAnonymousUser && !unreadMarkerReportActionID && mostRecentReportActionCreated > unreadMarkerTime) { + setUnreadMarkerTime(mostRecentReportActionCreated); + } + + return { + unreadMarkerReportActionID, + unreadMarkerReportActionIndex, + }; +} + +export default useUnreadMarker; +export type {UseUnreadMarkerParams, UseUnreadMarkerResult}; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index e7d54fb44b6a..70b910428927 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,9 +1,8 @@ -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'; @@ -11,8 +10,8 @@ import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatLis import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -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 FS from '@libs/Fullstory'; import durationHighlightItem from '@libs/Navigation/helpers/getDurationHighlightItem'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; @@ -34,14 +33,12 @@ import { getFirstVisibleReportActionID, getReportActionMessage, isConsecutiveActionMadeByPreviousActor, - isCurrentActionUnread, isDeletedParentAction, isNewerReportAction, isReportPreviewAction, isReversedTransaction, isSentMoneyReportAction, isTransactionThread, - wasMessageReceivedWhileOffline, } from '@libs/ReportActionsUtils'; import { canShowReportRecipientLocalTime, @@ -55,9 +52,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'; @@ -70,7 +65,6 @@ import type * as OnyxTypes from '@src/types/onyx'; import FloatingMessageCounter from './FloatingMessageCounter'; import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; -import {getUnreadMarkerReportAction} from './shouldDisplayNewMarkerOnReportAction'; import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; import useReportActionsNewActionLiveTail from './useReportActionsNewActionLiveTail'; import useReportUnreadMessageScrollTracking from './useReportUnreadMessageScrollTracking'; @@ -140,10 +134,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 @@ -188,19 +178,13 @@ function ReportActionsList({ const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {getLocalDateFromDatetime} = useLocalize(); - const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); + const {isOffline} = useNetworkWithOfflineStatus(); const route = useRoute>(); const reportScrollManager = useReportScrollManager(); const {scrollOffsetRef} = useContext(ActionListContext); const {draftReportAction, hasActiveDraft} = 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 [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT); @@ -215,15 +199,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); @@ -237,77 +212,23 @@ function ReportActionsList({ ); const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); - const reportLastReadTime = report.lastReadTime ?? ''; - - /** - * 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, + oldestUnreadReportAction, + scrollingVerticalOffset: scrollOffsetRef, + 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; + scrollingVerticalOffset: scrollOffsetRef, + hasNewerActions, + }); const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); @@ -355,31 +276,6 @@ function ReportActionsList({ 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; @@ -388,56 +284,13 @@ 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); const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ reportID: report.reportID, currentVerticalScrollingOffsetRef: scrollOffsetRef, - readActionSkippedRef: readActionSkipped, + readActionSkippedRef, hasNewerActions, unreadMarkerReportActionIndex, isInverted: true, @@ -498,123 +351,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; @@ -682,7 +423,7 @@ 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]); From 7a5715f1496b653aaa79275f1a7d5fee3d6d80ce Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 20 May 2026 18:30:12 +0200 Subject: [PATCH 02/12] fix unread marker first-render derivation --- src/hooks/useUnreadMarker.ts | 184 +++++++++++++++++++++-------------- 1 file changed, 110 insertions(+), 74 deletions(-) diff --git a/src/hooks/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts index 9ac292cda518..feceba441e79 100644 --- a/src/hooks/useUnreadMarker.ts +++ b/src/hooks/useUnreadMarker.ts @@ -27,8 +27,77 @@ type UseUnreadMarkerResult = { unreadMarkerReportActionIndex: number; }; +type ComputeMarkerInput = { + sortedReportActions: OnyxTypes.ReportAction[]; + sortedVisibleReportActions: OnyxTypes.ReportAction[]; + oldestUnreadReportAction: OnyxEntry; + hasOnceLoadedReportActions: boolean; + prevSortedVisibleReportActionsObjects: OnyxTypes.ReportActions; + currentUserAccountID: number; + unreadMarkerTime: string; + scrollingVerticalOffset: number; + prevUnreadMarkerReportActionID: string | null; + isOffline: boolean; + isAnonymousUser: boolean; + lastOfflineAt: Date | undefined; + lastOnlineAt: Date | undefined; + getLocalDateFromDatetime: (datetime: string | undefined) => Date; +}; + const lastReadTimeSelector = (report: OnyxTypes.Report | undefined) => report?.lastReadTime ?? ''; +function computeUnreadMarker({ + sortedReportActions, + sortedVisibleReportActions, + oldestUnreadReportAction, + hasOnceLoadedReportActions, + prevSortedVisibleReportActionsObjects, + currentUserAccountID, + unreadMarkerTime, + scrollingVerticalOffset, + prevUnreadMarkerReportActionID, + isOffline, + isAnonymousUser, + lastOfflineAt, + lastOnlineAt, + getLocalDateFromDatetime, +}: ComputeMarkerInput): [string | null, number] { + let earliestReceivedOfflineMessageIndex: number | undefined; + for (let i = sortedReportActions.length - 1; i >= 0; i--) { + const message = sortedReportActions.at(i); + if (message && wasMessageReceivedWhileOffline(message, isOffline, lastOfflineAt, lastOnlineAt, 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 (oldestUnreadReportAction && !hasOnceLoadedReportActions) { + const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportAction.reportActionID); + if (visibleIndex >= 0) { + oldestUnreadReportActionMarker = [oldestUnreadReportAction.reportActionID, visibleIndex]; + } + } + + const scanned = getUnreadMarkerReportAction({ + visibleReportActions: sortedVisibleReportActions, + earliestReceivedOfflineMessageIndex, + currentUserAccountID, + prevSortedVisibleReportActionsObjects, + unreadMarkerTime, + scrollingVerticalOffset, + prevUnreadMarkerReportActionID, + 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. + return oldestUnreadReportActionMarker && (scanned[0] === null || scanned[0] === oldestUnreadReportActionMarker[0]) ? oldestUnreadReportActionMarker : scanned; +} + function useUnreadMarker({ reportID, sortedVisibleReportActions, @@ -47,30 +116,35 @@ function useUnreadMarker({ }); const reportLastReadTime = reportLastReadTimeValue ?? ''; - const prevUnreadMarkerReportActionID = useRef(null); + const prevUnreadMarkerReportActionIDRef = useRef(null); const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime); - const [markerState, setMarkerState] = useState<[string | null, number]>([null, -1]); - // Reset the marker time when switching between reports — same instance is reused per report. - useEffect(() => { - setUnreadMarkerTime(reportLastReadTime); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportID]); + 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); - // 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]); + const [markerState, setMarkerState] = useState<[string | null, number]>(() => + computeUnreadMarker({ + sortedReportActions, + sortedVisibleReportActions, + oldestUnreadReportAction, + hasOnceLoadedReportActions, + prevSortedVisibleReportActionsObjects: sortedVisibleReportActionsObjects, + currentUserAccountID, + unreadMarkerTime: reportLastReadTime, + scrollingVerticalOffset: scrollingVerticalOffset.current ?? 0, + prevUnreadMarkerReportActionID: null, + isOffline, + isAnonymousUser, + lastOfflineAt: lastOfflineAt.current, + lastOnlineAt: lastOnlineAt.current, + getLocalDateFromDatetime, + }), + ); - /** - * Subscribe to read/unread events and update our unreadMarkerTime. - */ useEffect(() => { if (isAnonymousUser) { return; @@ -89,76 +163,38 @@ function useUnreadMarker({ }; }, [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); - - // Reading refs at render-time would bail the compiler. Wrap the marker computation in an - // effect-event so the latest ref values are read at commit time, and store the result in state. - const computeMarker = useEffectEvent(() => { - 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 (oldestUnreadReportAction && !hasOnceLoadedReportActions) { - const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportAction.reportActionID); - if (visibleIndex >= 0) { - oldestUnreadReportActionMarker = [oldestUnreadReportAction.reportActionID, visibleIndex]; - } - } - - const scanned = getUnreadMarkerReportAction({ - visibleReportActions: sortedVisibleReportActions, - earliestReceivedOfflineMessageIndex, - currentUserAccountID, + // useEffectEvent gives the recompute access to live ref values without bailing the compiler. + const recomputeMarker = useEffectEvent(() => { + const next = computeUnreadMarker({ + sortedReportActions, + sortedVisibleReportActions, + oldestUnreadReportAction, + hasOnceLoadedReportActions, prevSortedVisibleReportActionsObjects, + currentUserAccountID, unreadMarkerTime, scrollingVerticalOffset: scrollingVerticalOffset.current ?? 0, - prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, + prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionIDRef.current, isOffline, - isReversed: false, isAnonymousUser, + lastOfflineAt: lastOfflineAt.current, + lastOnlineAt: lastOnlineAt.current, + getLocalDateFromDatetime, }); - - let next: [string | null, number]; - 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) { - next = scanned; - } else { - next = oldestUnreadReportActionMarker; - } - } else { - next = scanned; - } - - prevUnreadMarkerReportActionID.current = next[0]; + prevUnreadMarkerReportActionIDRef.current = next[0]; setMarkerState((prev) => (prev[0] === next[0] && prev[1] === next[1] ? prev : next)); }); useLayoutEffect(() => { - computeMarker(); - // eslint-disable-next-line react-hooks/exhaustive-deps + recomputeMarker(); }, [sortedVisibleReportActions, sortedReportActions, oldestUnreadReportAction, hasOnceLoadedReportActions, isOffline, unreadMarkerTime, currentUserAccountID, isAnonymousUser]); const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = markerState; // 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. Adjusting state during render (vs. a layout effect) is - // the React-recommended pattern — avoids cascading commits. + // sticking to the initial lastReadTime. Adjusting state during render is the React-recommended + // pattern — avoids cascading commits. // https://react.dev/reference/react/useState#storing-information-from-previous-renders const lastAction = sortedVisibleReportActions.at(0); const mostRecentReportActionCreated = lastAction?.created ?? ''; From b696eb3e812eef89b4252dada265c0c7e3480b80 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 20 May 2026 18:55:30 +0200 Subject: [PATCH 03/12] fix eslint --- src/hooks/useMarkAsRead.ts | 1 - src/pages/inbox/report/ReportActionsList.tsx | 13 ++++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts index c56ce238f735..57a01b53811a 100644 --- a/src/hooks/useMarkAsRead.ts +++ b/src/hooks/useMarkAsRead.ts @@ -151,7 +151,6 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible if (!isMarkedAsRead) { handleAppVisibilityMarkAsRead(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [report?.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, reportID, isVisible, isFocused, reportLoadingState?.hasOnceLoadedReportActions]); return {readActionSkippedRef}; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 70b910428927..81cfe45a891f 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -2,6 +2,7 @@ import {useRoute} from '@react-navigation/native'; import type {ListRenderItemInfo} from '@shopify/flash-list'; 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 {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; @@ -425,7 +426,17 @@ function ReportActionsList({ reportScrollManager.scrollToBottom(); 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, + ]); /** * Thread's divider line should hide when the first chat in the thread is marked as unread. From 7013c9d79f22908e1cf08fa8e59bc17b50d28ab3 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 21 May 2026 10:38:08 +0200 Subject: [PATCH 04/12] match pre-PR4 mark-as-read pattern --- src/hooks/useMarkAsRead.ts | 77 +++++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts index 57a01b53811a..b5b2cde2ad46 100644 --- a/src/hooks/useMarkAsRead.ts +++ b/src/hooks/useMarkAsRead.ts @@ -1,6 +1,6 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import type {RefObject} from 'react'; -import {useEffect, useEffectEvent, useRef, useSyncExternalStore} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {DeviceEventEmitter} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import DateUtils from '@libs/DateUtils'; @@ -44,25 +44,30 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); - const isVisible = useSyncExternalStore(Visibility.onVisibilityChange, Visibility.isVisible); + 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 prevHandleReportChangeMarkAsReadRef = useRef<(() => void) | null>(null); + const prevHandleAppVisibilityMarkAsReadRef = useRef<(() => void) | null>(null); const lastAction = sortedVisibleReportActions.at(0); const isReportUnreadValue = isUnread(report, transactionThreadReport, isReportArchived) || (!!lastAction && isCurrentActionUnread(report, lastAction)); - // Refresh userActiveSince + reset initial-mount gate when the same component instance is reused for another report. useEffect(() => { userActiveSince.current = DateUtils.getDBTime(); didMarkReportAsReadInitially.current = false; prevReportID = reportID; }, [reportID]); - // The unread-marker hook also subscribes to `unreadAction_${reportID}` to update the marker - // time. We subscribe separately so this hook owns the userActiveSince concern. useEffect(() => { const subscription = DeviceEventEmitter.addListener(`unreadAction_${reportID}`, () => { userActiveSince.current = DateUtils.getDBTime(); @@ -70,7 +75,6 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible return () => subscription.remove(); }, [reportID]); - // Mark the report as read when the user initially opens the report and there are unread messages. useEffect(() => { if (!isReportUnreadValue || didMarkReportAsReadInitially.current) { didMarkReportAsReadInitially.current = true; @@ -81,18 +85,15 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); }, [isReportUnreadValue, reportID, reportLoadingState?.hasOnceLoadedReportActions]); - const handleReportChangeMarkAsRead = useEffectEvent(() => { + const handleReportChangeMarkAsRead = useCallback(() => { if (reportID !== prevReportID) { - return false; + return; } const isLastActionUnread = !!lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions); if (!isUnread(report, transactionThreadReport, isReportArchived) && !isLastActionUnread) { - return false; + 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 = scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; @@ -101,18 +102,25 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible if (isFromNotification) { Navigation.setParams({referrer: undefined}); } - return true; + return; } readActionSkippedRef.current = true; - return false; - }); - - // This `mark as read` will only run when the focused report has new messages and the App visibility - // changes to visible (user switched to the app/web while previously on another tab/application). - // We mark the report as read so the LHN report item is marked read while the unread marker still - // shows the messages received while the user wasn't focused on the report. - const handleAppVisibilityMarkAsRead = useEffectEvent(() => { + }, [ + report, + transactionThreadReport, + isReportArchived, + hasNewerActions, + isVisible, + lastAction, + route?.params?.referrer, + reportLoadingState?.hasOnceLoadedReportActions, + reportID, + scrollingVerticalOffset, + sortedVisibleReportActions, + ]); + + const handleAppVisibilityMarkAsRead = useCallback(() => { if (reportID !== prevReportID) { return; } @@ -124,7 +132,6 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible return; } - // If the user read new messages (after being inactive) on another device, show the marker based on report.lastReadTime. const newMessageTimeReference = lastMessageTime.current && report?.lastReadTime && lastMessageTime.current > report.lastReadTime ? userActiveSince.current : report?.lastReadTime; lastMessageTime.current = null; @@ -143,15 +150,31 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); userActiveSince.current = DateUtils.getDBTime(); - }); + }, [ + reportID, + isVisible, + isFocused, + lastAction?.created, + report, + isReportArchived, + sortedVisibleReportActions, + currentUserAccountID, + scrollingVerticalOffset, + reportLoadingState?.hasOnceLoadedReportActions, + ]); useEffect(() => { - // handleReportChangeMarkAsRead short-circuits via its return value to prevent duplicate readNewestAction calls. - const isMarkedAsRead = handleReportChangeMarkAsRead(); - if (!isMarkedAsRead) { + if (handleReportChangeMarkAsRead !== prevHandleReportChangeMarkAsReadRef.current) { + handleReportChangeMarkAsRead(); + } + + if (handleAppVisibilityMarkAsRead !== prevHandleAppVisibilityMarkAsReadRef.current) { handleAppVisibilityMarkAsRead(); } - }, [report?.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, reportID, isVisible, isFocused, reportLoadingState?.hasOnceLoadedReportActions]); + + prevHandleReportChangeMarkAsReadRef.current = handleReportChangeMarkAsRead; + prevHandleAppVisibilityMarkAsReadRef.current = handleAppVisibilityMarkAsRead; + }, [handleReportChangeMarkAsRead, handleAppVisibilityMarkAsRead]); return {readActionSkippedRef}; } From b89dcb3b1e087ad38b0351332c09843f639a66e7 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 21 May 2026 15:06:31 +0200 Subject: [PATCH 05/12] drop manual useCallback --- src/hooks/useMarkAsRead.ts | 35 ++++++----------------------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts index b5b2cde2ad46..fd2e95cf1935 100644 --- a/src/hooks/useMarkAsRead.ts +++ b/src/hooks/useMarkAsRead.ts @@ -1,6 +1,6 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import type {RefObject} from 'react'; -import {useCallback, useEffect, useRef, useState} 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'; @@ -85,7 +85,7 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); }, [isReportUnreadValue, reportID, reportLoadingState?.hasOnceLoadedReportActions]); - const handleReportChangeMarkAsRead = useCallback(() => { + const handleReportChangeMarkAsRead = () => { if (reportID !== prevReportID) { return; } @@ -106,21 +106,9 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible } readActionSkippedRef.current = true; - }, [ - report, - transactionThreadReport, - isReportArchived, - hasNewerActions, - isVisible, - lastAction, - route?.params?.referrer, - reportLoadingState?.hasOnceLoadedReportActions, - reportID, - scrollingVerticalOffset, - sortedVisibleReportActions, - ]); - - const handleAppVisibilityMarkAsRead = useCallback(() => { + }; + + const handleAppVisibilityMarkAsRead = () => { if (reportID !== prevReportID) { return; } @@ -150,18 +138,7 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); userActiveSince.current = DateUtils.getDBTime(); - }, [ - reportID, - isVisible, - isFocused, - lastAction?.created, - report, - isReportArchived, - sortedVisibleReportActions, - currentUserAccountID, - scrollingVerticalOffset, - reportLoadingState?.hasOnceLoadedReportActions, - ]); + }; useEffect(() => { if (handleReportChangeMarkAsRead !== prevHandleReportChangeMarkAsReadRef.current) { From 739bdc41feca2a6348de26f2269854c200a83399 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 21 May 2026 16:08:20 +0200 Subject: [PATCH 06/12] restore useEffectEvent for mark-as-read handlers Co-Authored-By: Claude Opus 4.7 --- src/hooks/useMarkAsRead.ts | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts index fd2e95cf1935..b3835fd4ca09 100644 --- a/src/hooks/useMarkAsRead.ts +++ b/src/hooks/useMarkAsRead.ts @@ -1,6 +1,6 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import type {RefObject} from 'react'; -import {useEffect, useRef, useState} from 'react'; +import {useEffect, useEffectEvent, useRef, useState} from 'react'; import {DeviceEventEmitter} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import DateUtils from '@libs/DateUtils'; @@ -56,8 +56,6 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible const userActiveSince = useRef(DateUtils.getDBTime()); const lastMessageTime = useRef(null); const didMarkReportAsReadInitially = useRef(false); - const prevHandleReportChangeMarkAsReadRef = useRef<(() => void) | null>(null); - const prevHandleAppVisibilityMarkAsReadRef = useRef<(() => void) | null>(null); const lastAction = sortedVisibleReportActions.at(0); const isReportUnreadValue = isUnread(report, transactionThreadReport, isReportArchived) || (!!lastAction && isCurrentActionUnread(report, lastAction)); @@ -85,14 +83,14 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); }, [isReportUnreadValue, reportID, reportLoadingState?.hasOnceLoadedReportActions]); - const handleReportChangeMarkAsRead = () => { + const handleReportChangeMarkAsRead = useEffectEvent(() => { if (reportID !== prevReportID) { - return; + return false; } const isLastActionUnread = !!lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions); if (!isUnread(report, transactionThreadReport, isReportArchived) && !isLastActionUnread) { - return; + return false; } const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION; const isScrolledToEnd = scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; @@ -102,13 +100,14 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible if (isFromNotification) { Navigation.setParams({referrer: undefined}); } - return; + return true; } readActionSkippedRef.current = true; - }; + return false; + }); - const handleAppVisibilityMarkAsRead = () => { + const handleAppVisibilityMarkAsRead = useEffectEvent(() => { if (reportID !== prevReportID) { return; } @@ -138,20 +137,14 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); userActiveSince.current = DateUtils.getDBTime(); - }; + }); useEffect(() => { - if (handleReportChangeMarkAsRead !== prevHandleReportChangeMarkAsReadRef.current) { - handleReportChangeMarkAsRead(); - } - - if (handleAppVisibilityMarkAsRead !== prevHandleAppVisibilityMarkAsReadRef.current) { + const isMarkedAsRead = handleReportChangeMarkAsRead(); + if (!isMarkedAsRead) { handleAppVisibilityMarkAsRead(); } - - prevHandleReportChangeMarkAsReadRef.current = handleReportChangeMarkAsRead; - prevHandleAppVisibilityMarkAsReadRef.current = handleAppVisibilityMarkAsRead; - }, [handleReportChangeMarkAsRead, handleAppVisibilityMarkAsRead]); + }, [report?.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, reportID, isVisible, isFocused, reportLoadingState?.hasOnceLoadedReportActions]); return {readActionSkippedRef}; } From 378ac7efb8c61d6f19e704cc80461882436bcb50 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 21 May 2026 16:58:45 +0200 Subject: [PATCH 07/12] gate unread marker push-down on lastAction.created Co-Authored-By: Claude Opus 4.7 --- src/hooks/useUnreadMarker.ts | 197 +++++++++++++++-------------------- 1 file changed, 84 insertions(+), 113 deletions(-) diff --git a/src/hooks/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts index feceba441e79..70d558238956 100644 --- a/src/hooks/useUnreadMarker.ts +++ b/src/hooks/useUnreadMarker.ts @@ -1,5 +1,5 @@ import type {RefObject} from 'react'; -import {useEffect, useEffectEvent, useLayoutEffect, useRef, useState} from 'react'; +import {useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {wasMessageReceivedWhileOffline} from '@libs/ReportActionsUtils'; @@ -27,77 +27,8 @@ type UseUnreadMarkerResult = { unreadMarkerReportActionIndex: number; }; -type ComputeMarkerInput = { - sortedReportActions: OnyxTypes.ReportAction[]; - sortedVisibleReportActions: OnyxTypes.ReportAction[]; - oldestUnreadReportAction: OnyxEntry; - hasOnceLoadedReportActions: boolean; - prevSortedVisibleReportActionsObjects: OnyxTypes.ReportActions; - currentUserAccountID: number; - unreadMarkerTime: string; - scrollingVerticalOffset: number; - prevUnreadMarkerReportActionID: string | null; - isOffline: boolean; - isAnonymousUser: boolean; - lastOfflineAt: Date | undefined; - lastOnlineAt: Date | undefined; - getLocalDateFromDatetime: (datetime: string | undefined) => Date; -}; - const lastReadTimeSelector = (report: OnyxTypes.Report | undefined) => report?.lastReadTime ?? ''; -function computeUnreadMarker({ - sortedReportActions, - sortedVisibleReportActions, - oldestUnreadReportAction, - hasOnceLoadedReportActions, - prevSortedVisibleReportActionsObjects, - currentUserAccountID, - unreadMarkerTime, - scrollingVerticalOffset, - prevUnreadMarkerReportActionID, - isOffline, - isAnonymousUser, - lastOfflineAt, - lastOnlineAt, - getLocalDateFromDatetime, -}: ComputeMarkerInput): [string | null, number] { - let earliestReceivedOfflineMessageIndex: number | undefined; - for (let i = sortedReportActions.length - 1; i >= 0; i--) { - const message = sortedReportActions.at(i); - if (message && wasMessageReceivedWhileOffline(message, isOffline, lastOfflineAt, lastOnlineAt, 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 (oldestUnreadReportAction && !hasOnceLoadedReportActions) { - const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportAction.reportActionID); - if (visibleIndex >= 0) { - oldestUnreadReportActionMarker = [oldestUnreadReportAction.reportActionID, visibleIndex]; - } - } - - const scanned = getUnreadMarkerReportAction({ - visibleReportActions: sortedVisibleReportActions, - earliestReceivedOfflineMessageIndex, - currentUserAccountID, - prevSortedVisibleReportActionsObjects, - unreadMarkerTime, - scrollingVerticalOffset, - prevUnreadMarkerReportActionID, - 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. - return oldestUnreadReportActionMarker && (scanned[0] === null || scanned[0] === oldestUnreadReportActionMarker[0]) ? oldestUnreadReportActionMarker : scanned; -} - function useUnreadMarker({ reportID, sortedVisibleReportActions, @@ -116,34 +47,23 @@ function useUnreadMarker({ }); const reportLastReadTime = reportLastReadTimeValue ?? ''; - const prevUnreadMarkerReportActionIDRef = useRef(null); const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime); + const prevUnreadMarkerReportActionIDRef = useRef(null); - 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); + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setUnreadMarkerTime(reportLastReadTime); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportID]); - const [markerState, setMarkerState] = useState<[string | null, number]>(() => - computeUnreadMarker({ - sortedReportActions, - sortedVisibleReportActions, - oldestUnreadReportAction, - hasOnceLoadedReportActions, - prevSortedVisibleReportActionsObjects: sortedVisibleReportActionsObjects, - currentUserAccountID, - unreadMarkerTime: reportLastReadTime, - scrollingVerticalOffset: scrollingVerticalOffset.current ?? 0, - prevUnreadMarkerReportActionID: null, - isOffline, - isAnonymousUser, - lastOfflineAt: lastOfflineAt.current, - lastOnlineAt: lastOnlineAt.current, - getLocalDateFromDatetime, - }), - ); + useEffect(() => { + if (reportLastReadTime === '' || unreadMarkerTime !== '') { + return; + } + // eslint-disable-next-line react-hooks/set-state-in-effect + setUnreadMarkerTime(reportLastReadTime); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportLastReadTime]); useEffect(() => { if (isAnonymousUser) { @@ -163,44 +83,95 @@ function useUnreadMarker({ }; }, [reportID, isAnonymousUser]); - // useEffectEvent gives the recompute access to live ref values without bailing the compiler. + const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( + () => + sortedVisibleReportActions.reduce((actions, action) => { + // eslint-disable-next-line no-param-reassign + actions[action.reportActionID] = action; + return actions; + }, {}), + [sortedVisibleReportActions], + ); + const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); + + const earliestReceivedOfflineMessageIndex = useMemo(() => { + for (let i = sortedReportActions.length - 1; i >= 0; i--) { + const message = sortedReportActions.at(i); + if (message && wasMessageReceivedWhileOffline(message, isOffline, lastOfflineAt.current, lastOnlineAt.current, getLocalDateFromDatetime)) { + return i; + } + } + return undefined; + }, [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 || hasOnceLoadedReportActions) { + return undefined; + } + const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportAction.reportActionID); + if (visibleIndex < 0) { + return undefined; + } + return [oldestUnreadReportAction.reportActionID, visibleIndex]; + }, [oldestUnreadReportAction, hasOnceLoadedReportActions, sortedVisibleReportActions]); + + const [markerState, setMarkerState] = useState<[string | null, number]>([null, -1]); + const recomputeMarker = useEffectEvent(() => { - const next = computeUnreadMarker({ - sortedReportActions, - sortedVisibleReportActions, - oldestUnreadReportAction, - hasOnceLoadedReportActions, - prevSortedVisibleReportActionsObjects, + const scanned = getUnreadMarkerReportAction({ + visibleReportActions: sortedVisibleReportActions, + earliestReceivedOfflineMessageIndex, currentUserAccountID, + prevSortedVisibleReportActionsObjects, unreadMarkerTime, - scrollingVerticalOffset: scrollingVerticalOffset.current ?? 0, + scrollingVerticalOffset: scrollingVerticalOffset.current, prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionIDRef.current, isOffline, + isReversed: false, isAnonymousUser, - lastOfflineAt: lastOfflineAt.current, - lastOnlineAt: lastOnlineAt.current, - getLocalDateFromDatetime, }); + let next: [string | null, number]; + if (oldestUnreadReportActionMarker && (scanned[0] === null || scanned[0] === oldestUnreadReportActionMarker[0])) { + next = oldestUnreadReportActionMarker; + } else { + next = scanned; + } prevUnreadMarkerReportActionIDRef.current = next[0]; setMarkerState((prev) => (prev[0] === next[0] && prev[1] === next[1] ? prev : next)); }); useLayoutEffect(() => { recomputeMarker(); - }, [sortedVisibleReportActions, sortedReportActions, oldestUnreadReportAction, hasOnceLoadedReportActions, isOffline, unreadMarkerTime, currentUserAccountID, isAnonymousUser]); + }, [ + currentUserAccountID, + earliestReceivedOfflineMessageIndex, + isAnonymousUser, + isOffline, + oldestUnreadReportActionMarker, + prevSortedVisibleReportActionsObjects, + sortedVisibleReportActions, + unreadMarkerTime, + ]); const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = markerState; // 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. Adjusting state during render is the React-recommended - // pattern — avoids cascading commits. - // https://react.dev/reference/react/useState#storing-information-from-previous-renders + // sticking to the initial lastReadTime. const lastAction = sortedVisibleReportActions.at(0); - const mostRecentReportActionCreated = lastAction?.created ?? ''; - if (!isAnonymousUser && !unreadMarkerReportActionID && mostRecentReportActionCreated > unreadMarkerTime) { + useLayoutEffect(() => { + if (isAnonymousUser || unreadMarkerReportActionID) { + return; + } + const mostRecentReportActionCreated = lastAction?.created ?? ''; + if (mostRecentReportActionCreated <= unreadMarkerTime) { + return; + } + // eslint-disable-next-line react-hooks/set-state-in-effect setUnreadMarkerTime(mostRecentReportActionCreated); - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastAction?.created]); return { unreadMarkerReportActionID, From ab4d7577ab39321c6fff8241bb4c545cf8d687b6 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Thu, 21 May 2026 18:01:15 +0200 Subject: [PATCH 08/12] inline mark-as-read handlers and split by trigger deps Co-Authored-By: Claude Opus 4.7 --- src/hooks/useMarkAsRead.ts | 34 ++++++++++++++++++---------------- src/hooks/useUnreadMarker.ts | 19 +++++++------------ 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts index b3835fd4ca09..8735ce123ac7 100644 --- a/src/hooks/useMarkAsRead.ts +++ b/src/hooks/useMarkAsRead.ts @@ -1,6 +1,6 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import type {RefObject} from 'react'; -import {useEffect, useEffectEvent, useRef, useState} 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'; @@ -83,14 +83,17 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); }, [isReportUnreadValue, reportID, reportLoadingState?.hasOnceLoadedReportActions]); - const handleReportChangeMarkAsRead = useEffectEvent(() => { + const didMarkOnReportChangeRef = useRef(false); + + useEffect(() => { + didMarkOnReportChangeRef.current = false; if (reportID !== prevReportID) { - return false; + return; } const isLastActionUnread = !!lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions); if (!isUnread(report, transactionThreadReport, isReportArchived) && !isLastActionUnread) { - return false; + return; } const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION; const isScrolledToEnd = scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; @@ -100,14 +103,19 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible if (isFromNotification) { Navigation.setParams({referrer: undefined}); } - return true; + didMarkOnReportChangeRef.current = true; + return; } readActionSkippedRef.current = true; - return false; - }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [report?.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, reportID, isVisible, reportLoadingState?.hasOnceLoadedReportActions]); - const handleAppVisibilityMarkAsRead = useEffectEvent(() => { + useEffect(() => { + if (didMarkOnReportChangeRef.current) { + didMarkOnReportChangeRef.current = false; + return; + } if (reportID !== prevReportID) { return; } @@ -137,14 +145,8 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); userActiveSince.current = DateUtils.getDBTime(); - }); - - useEffect(() => { - const isMarkedAsRead = handleReportChangeMarkAsRead(); - if (!isMarkedAsRead) { - handleAppVisibilityMarkAsRead(); - } - }, [report?.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, reportID, isVisible, isFocused, reportLoadingState?.hasOnceLoadedReportActions]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVisible, isFocused, reportLoadingState?.hasOnceLoadedReportActions]); return {readActionSkippedRef}; } diff --git a/src/hooks/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts index 70d558238956..be618019ffe4 100644 --- a/src/hooks/useUnreadMarker.ts +++ b/src/hooks/useUnreadMarker.ts @@ -1,5 +1,5 @@ import type {RefObject} from 'react'; -import {useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {wasMessageReceivedWhileOffline} from '@libs/ReportActionsUtils'; @@ -118,7 +118,7 @@ function useUnreadMarker({ const [markerState, setMarkerState] = useState<[string | null, number]>([null, -1]); - const recomputeMarker = useEffectEvent(() => { + useLayoutEffect(() => { const scanned = getUnreadMarkerReportAction({ visibleReportActions: sortedVisibleReportActions, earliestReceivedOfflineMessageIndex, @@ -131,18 +131,12 @@ function useUnreadMarker({ isReversed: false, isAnonymousUser, }); - let next: [string | null, number]; - if (oldestUnreadReportActionMarker && (scanned[0] === null || scanned[0] === oldestUnreadReportActionMarker[0])) { - next = oldestUnreadReportActionMarker; - } else { - next = scanned; - } + // 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 next: [string | null, number] = + oldestUnreadReportActionMarker && (scanned[0] === null || scanned[0] === oldestUnreadReportActionMarker[0]) ? oldestUnreadReportActionMarker : scanned; prevUnreadMarkerReportActionIDRef.current = next[0]; setMarkerState((prev) => (prev[0] === next[0] && prev[1] === next[1] ? prev : next)); - }); - - useLayoutEffect(() => { - recomputeMarker(); }, [ currentUserAccountID, earliestReceivedOfflineMessageIndex, @@ -152,6 +146,7 @@ function useUnreadMarker({ prevSortedVisibleReportActionsObjects, sortedVisibleReportActions, unreadMarkerTime, + scrollingVerticalOffset, ]); const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = markerState; From b43d5b2cb21cf323d06ec10738394c95878f1f2a Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 29 May 2026 15:14:35 +0200 Subject: [PATCH 09/12] make unread marker compiler-clean; drop own-message marker gate --- .../MoneyRequestReportActionsList.tsx | 6 +- src/hooks/useUnreadMarker.ts | 127 ++++++------------ src/pages/inbox/report/ReportActionsList.tsx | 5 +- .../shouldDisplayNewMarkerOnReportAction.ts | 30 ++--- tests/unit/ReportActionsUtilsTest.ts | 6 +- 5 files changed, 60 insertions(+), 114 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 774b340a0bc9..e889a276d01a 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -301,8 +301,6 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) loadNewerChats(false); }, [loadNewerChats]); - const prevUnreadMarkerReportActionID = useRef(null); - const visibleActionsMap = useMemo(() => { return visibleReportActions.reduce((actionsMap, reportAction) => { Object.assign(actionsMap, { @@ -413,12 +411,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/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts index be618019ffe4..bd1486697437 100644 --- a/src/hooks/useUnreadMarker.ts +++ b/src/hooks/useUnreadMarker.ts @@ -1,5 +1,4 @@ -import type {RefObject} from 'react'; -import {useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {useEffect, useState} from 'react'; import {DeviceEventEmitter} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {wasMessageReceivedWhileOffline} from '@libs/ReportActionsUtils'; @@ -18,7 +17,7 @@ type UseUnreadMarkerParams = { sortedVisibleReportActions: OnyxTypes.ReportAction[]; sortedReportActions: OnyxTypes.ReportAction[]; oldestUnreadReportAction: OnyxEntry; - scrollingVerticalOffset: RefObject; + isScrolledOverThreshold: boolean; hasOnceLoadedReportActions: boolean; }; @@ -34,7 +33,7 @@ function useUnreadMarker({ sortedVisibleReportActions, sortedReportActions, oldestUnreadReportAction, - scrollingVerticalOffset, + isScrolledOverThreshold, hasOnceLoadedReportActions, }: UseUnreadMarkerParams): UseUnreadMarkerResult { const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -48,22 +47,16 @@ function useUnreadMarker({ const reportLastReadTime = reportLastReadTimeValue ?? ''; const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime); - const prevUnreadMarkerReportActionIDRef = useRef(null); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect + const [trackedReportID, setTrackedReportID] = useState(reportID); + if (trackedReportID !== reportID) { + setTrackedReportID(reportID); setUnreadMarkerTime(reportLastReadTime); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportID]); + } - useEffect(() => { - if (reportLastReadTime === '' || unreadMarkerTime !== '') { - return; - } - // eslint-disable-next-line react-hooks/set-state-in-effect + if (unreadMarkerTime === '' && reportLastReadTime !== '') { setUnreadMarkerTime(reportLastReadTime); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportLastReadTime]); + } useEffect(() => { if (isAnonymousUser) { @@ -83,90 +76,54 @@ function useUnreadMarker({ }; }, [reportID, isAnonymousUser]); - const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( - () => - sortedVisibleReportActions.reduce((actions, action) => { - // eslint-disable-next-line no-param-reassign - actions[action.reportActionID] = action; - return actions; - }, {}), - [sortedVisibleReportActions], - ); + 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); - const earliestReceivedOfflineMessageIndex = useMemo(() => { - for (let i = sortedReportActions.length - 1; i >= 0; i--) { - const message = sortedReportActions.at(i); - if (message && wasMessageReceivedWhileOffline(message, isOffline, lastOfflineAt.current, lastOnlineAt.current, getLocalDateFromDatetime)) { - return i; - } + 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; } - return undefined; - }, [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 || hasOnceLoadedReportActions) { - return undefined; - } + let oldestUnreadReportActionMarker: [string, number] | undefined; + if (oldestUnreadReportAction && !hasOnceLoadedReportActions) { const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportAction.reportActionID); - if (visibleIndex < 0) { - return undefined; + if (visibleIndex >= 0) { + oldestUnreadReportActionMarker = [oldestUnreadReportAction.reportActionID, visibleIndex]; } - return [oldestUnreadReportAction.reportActionID, visibleIndex]; - }, [oldestUnreadReportAction, hasOnceLoadedReportActions, sortedVisibleReportActions]); - - const [markerState, setMarkerState] = useState<[string | null, number]>([null, -1]); - - useLayoutEffect(() => { - const scanned = getUnreadMarkerReportAction({ - visibleReportActions: sortedVisibleReportActions, - earliestReceivedOfflineMessageIndex, - currentUserAccountID, - prevSortedVisibleReportActionsObjects, - unreadMarkerTime, - scrollingVerticalOffset: scrollingVerticalOffset.current, - prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionIDRef.current, - 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 next: [string | null, number] = - oldestUnreadReportActionMarker && (scanned[0] === null || scanned[0] === oldestUnreadReportActionMarker[0]) ? oldestUnreadReportActionMarker : scanned; - prevUnreadMarkerReportActionIDRef.current = next[0]; - setMarkerState((prev) => (prev[0] === next[0] && prev[1] === next[1] ? prev : next)); - }, [ - currentUserAccountID, + } + + const scanned = getUnreadMarkerReportAction({ + visibleReportActions: sortedVisibleReportActions, earliestReceivedOfflineMessageIndex, - isAnonymousUser, - isOffline, - oldestUnreadReportActionMarker, + currentUserAccountID, prevSortedVisibleReportActionsObjects, - sortedVisibleReportActions, unreadMarkerTime, - scrollingVerticalOffset, - ]); - - const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = markerState; + 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 lastAction = sortedVisibleReportActions.at(0); - useLayoutEffect(() => { - if (isAnonymousUser || unreadMarkerReportActionID) { - return; - } - const mostRecentReportActionCreated = lastAction?.created ?? ''; - if (mostRecentReportActionCreated <= unreadMarkerTime) { - return; - } - // eslint-disable-next-line react-hooks/set-state-in-effect + const mostRecentReportActionCreated = sortedVisibleReportActions.at(0)?.created ?? ''; + if (!isAnonymousUser && !unreadMarkerReportActionID && mostRecentReportActionCreated > unreadMarkerTime) { setUnreadMarkerTime(mostRecentReportActionCreated); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastAction?.created]); + } return { unreadMarkerReportActionID, diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 50e33d0c463e..eb9106f36a95 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -216,12 +216,14 @@ function ReportActionsList({ ); const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); + const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useUnreadMarker({ reportID: report.reportID, sortedVisibleReportActions, sortedReportActions, oldestUnreadReportAction, - scrollingVerticalOffset: scrollOffsetRef, + isScrolledOverThreshold: hasScrolledOverThreshold, hasOnceLoadedReportActions: !!reportLoadingState?.hasOnceLoadedReportActions, }); @@ -283,7 +285,6 @@ 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; useEffect(() => { diff --git a/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts b/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts index 05ac1e9f782c..b245bf9c8356 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,16 @@ const shouldDisplayNewMarkerOnReportAction = ({ const isPreviouslyOptimistic = (isPendingAdd(prevSortedVisibleReportActionsObjects[message.reportActionID]) && !isPendingAdd(message)) || (!!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction); - const shouldIgnoreUnreadForCurrentUserMessage = !prevUnreadMarkerReportActionID && isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic); + // The marker never lands on the user's own new or just-confirmed message. This intentionally drops the former + // `!prevUnreadMarkerReportActionID` gate, which let the marker fall on the user's own message in a rare corner + // (a prior marker on someone else's message became read, leaving only the user's own new message unread). + const shouldIgnoreUnreadForCurrentUserMessage = isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic); if (isFromCurrentUser) { return !shouldIgnoreUnreadForCurrentUserMessage; } - return !isNewMessage || scrollingVerticalOffset >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + return !isNewMessage || isScrolledOverThreshold; }; export default shouldDisplayNewMarkerOnReportAction; @@ -107,11 +106,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 +129,7 @@ const getUnreadMarkerReportAction = ({ currentUserAccountID, prevSortedVisibleReportActionsObjects, unreadMarkerTime, - scrollingVerticalOffset, - prevUnreadMarkerReportActionID, + isScrolledOverThreshold, isOffline, isReversed, isAnonymousUser = false, @@ -175,8 +170,7 @@ const getUnreadMarkerReportAction = ({ currentUserAccountID, prevSortedVisibleReportActionsObjects, unreadMarkerTime, - scrollingVerticalOffset, - prevUnreadMarkerReportActionID, + isScrolledOverThreshold, isOffline, }); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index ae54db276d3c..0cc01f4a33b9 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -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,13 +5034,12 @@ 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, }), From f1320a4884a587dcadb19e3cf37a8ed2c26368c0 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Tue, 2 Jun 2026 21:54:02 +0200 Subject: [PATCH 10/12] address PR feedback: simplify marker gate, anon-gate mark-as-read, isScrolledToEnd boolean - shouldDisplayNewMarkerOnReportAction: drop redundant isFromCurrentUser && (only read when isFromCurrentUser) - useUnreadMarker: take oldestUnreadReportActionID string instead of the full action - useMarkAsRead: gate userActiveSince subscription behind isAnonymousUser; replace scroll ref with isScrolledToEnd boolean - ReportActionsList: derive marker/mark-as-read from a single >= threshold state (fixes boundary, removes ref-in-render) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/hooks/useMarkAsRead.ts | 15 ++++++++++----- src/hooks/useUnreadMarker.ts | 11 +++++------ src/pages/inbox/report/ReportActionsList.tsx | 8 ++++---- .../shouldDisplayNewMarkerOnReportAction.ts | 2 +- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts index 8735ce123ac7..31786fc18851 100644 --- a/src/hooks/useMarkAsRead.ts +++ b/src/hooks/useMarkAsRead.ts @@ -16,6 +16,7 @@ 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'; @@ -28,7 +29,7 @@ type UseMarkAsReadParams = { report: OnyxEntry; transactionThreadReport: OnyxEntry; sortedVisibleReportActions: OnyxTypes.ReportAction[]; - scrollingVerticalOffset: RefObject; + isScrolledToEnd: boolean; hasNewerActions: boolean; }; @@ -36,8 +37,9 @@ type UseMarkAsReadResult = { readActionSkippedRef: RefObject; }; -function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisibleReportActions, scrollingVerticalOffset, hasNewerActions}: UseMarkAsReadParams): UseMarkAsReadResult { +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); @@ -67,11 +69,15 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible }, [reportID]); useEffect(() => { + if (isAnonymousUser) { + return; + } + const subscription = DeviceEventEmitter.addListener(`unreadAction_${reportID}`, () => { userActiveSince.current = DateUtils.getDBTime(); }); return () => subscription.remove(); - }, [reportID]); + }, [reportID, isAnonymousUser]); useEffect(() => { if (!isReportUnreadValue || didMarkReportAsReadInitially.current) { @@ -96,7 +102,6 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible return; } const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION; - const isScrolledToEnd = scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; if ((isVisible || isFromNotification) && !hasNewerActions && isScrolledToEnd) { readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); @@ -131,7 +136,7 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible lastMessageTime.current = null; const isArchivedReport = isArchivedNonExpenseReport(report, isReportArchived); - const hasNewMessagesInView = scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + const hasNewMessagesInView = isScrolledToEnd; const hasUnreadReportAction = sortedVisibleReportActions.some( (reportAction) => newMessageTimeReference && diff --git a/src/hooks/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts index bd1486697437..0f60c75a9d94 100644 --- a/src/hooks/useUnreadMarker.ts +++ b/src/hooks/useUnreadMarker.ts @@ -1,6 +1,5 @@ import {useEffect, useState} from 'react'; import {DeviceEventEmitter} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import {wasMessageReceivedWhileOffline} from '@libs/ReportActionsUtils'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -16,7 +15,7 @@ type UseUnreadMarkerParams = { reportID: string; sortedVisibleReportActions: OnyxTypes.ReportAction[]; sortedReportActions: OnyxTypes.ReportAction[]; - oldestUnreadReportAction: OnyxEntry; + oldestUnreadReportActionID: string | undefined; isScrolledOverThreshold: boolean; hasOnceLoadedReportActions: boolean; }; @@ -32,7 +31,7 @@ function useUnreadMarker({ reportID, sortedVisibleReportActions, sortedReportActions, - oldestUnreadReportAction, + oldestUnreadReportActionID, isScrolledOverThreshold, hasOnceLoadedReportActions, }: UseUnreadMarkerParams): UseUnreadMarkerResult { @@ -94,10 +93,10 @@ function useUnreadMarker({ // Index must be in the same domain as FlatList `data` (sortedVisibleReportActions), not the paginated full chain. let oldestUnreadReportActionMarker: [string, number] | undefined; - if (oldestUnreadReportAction && !hasOnceLoadedReportActions) { - const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportAction.reportActionID); + if (oldestUnreadReportActionID && !hasOnceLoadedReportActions) { + const visibleIndex = sortedVisibleReportActions.findIndex((action) => action.reportActionID === oldestUnreadReportActionID); if (visibleIndex >= 0) { - oldestUnreadReportActionMarker = [oldestUnreadReportAction.reportActionID, visibleIndex]; + oldestUnreadReportActionMarker = [oldestUnreadReportActionID, visibleIndex]; } } diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index eed9ecf0717f..9d08ddf8cccc 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -224,13 +224,13 @@ function ReportActionsList({ ); const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); - const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => scrollOffsetRef.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useUnreadMarker({ reportID: report.reportID, sortedVisibleReportActions, sortedReportActions, - oldestUnreadReportAction, + oldestUnreadReportActionID: oldestUnreadReportAction?.reportActionID, isScrolledOverThreshold: hasScrolledOverThreshold, hasOnceLoadedReportActions: !!reportLoadingState?.hasOnceLoadedReportActions, }); @@ -240,7 +240,7 @@ function ReportActionsList({ report, transactionThreadReport, sortedVisibleReportActions, - scrollingVerticalOffset: scrollOffsetRef, + isScrolledToEnd: !hasScrolledOverThreshold, hasNewerActions, }); @@ -326,7 +326,7 @@ function ReportActionsList({ 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, diff --git a/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts b/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts index b245bf9c8356..5acd010e27a6 100644 --- a/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts +++ b/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts @@ -79,7 +79,7 @@ const shouldDisplayNewMarkerOnReportAction = ({ // The marker never lands on the user's own new or just-confirmed message. This intentionally drops the former // `!prevUnreadMarkerReportActionID` gate, which let the marker fall on the user's own message in a rare corner // (a prior marker on someone else's message became read, leaving only the user's own new message unread). - const shouldIgnoreUnreadForCurrentUserMessage = isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic); + const shouldIgnoreUnreadForCurrentUserMessage = isNewMessage || isPreviouslyOptimistic; if (isFromCurrentUser) { return !shouldIgnoreUnreadForCurrentUserMessage; From 7a28485d38bff4fab54e1c983ace25181642a072 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Tue, 2 Jun 2026 22:04:09 +0200 Subject: [PATCH 11/12] fix knip: drop unused type exports from useMarkAsRead/useUnreadMarker Co-Authored-By: Claude Opus 4.8 (1M context) --- src/hooks/useMarkAsRead.ts | 1 - src/hooks/useUnreadMarker.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts index 31786fc18851..518a21a852ad 100644 --- a/src/hooks/useMarkAsRead.ts +++ b/src/hooks/useMarkAsRead.ts @@ -157,4 +157,3 @@ function useMarkAsRead({reportID, report, transactionThreadReport, sortedVisible } export default useMarkAsRead; -export type {UseMarkAsReadParams, UseMarkAsReadResult}; diff --git a/src/hooks/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts index 0f60c75a9d94..a6172bd43e6c 100644 --- a/src/hooks/useUnreadMarker.ts +++ b/src/hooks/useUnreadMarker.ts @@ -131,4 +131,3 @@ function useUnreadMarker({ } export default useUnreadMarker; -export type {UseUnreadMarkerParams, UseUnreadMarkerResult}; From a4f88a1d951ef25bdc1a78be7c6aeed8c56bf9d4 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 3 Jun 2026 14:30:37 +0200 Subject: [PATCH 12/12] add unit tests for unread-marker scan and mark-as-read hooks Cover the PR4 change surface that previously had no tests: - shouldDisplayNewMarkerOnReportAction: own-message gate (new/optimistic), the isScrolledOverThreshold branches for others' messages - getUnreadMarkerReportAction: anonymous short-circuit, concierge skip, forward/reversed scan, offline-index start bound, no-match - useUnreadMarker: anonymous, marker placement, oldestUnread anchor vs scan, DeviceEventEmitter clearing the marker - useMarkAsRead: mark-on-mount, already-read no-op, readActionSkipped gating, notification-referrer path Drop the change-history comment from shouldDisplayNewMarkerOnReportAction; the variable name and branch describe the current behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shouldDisplayNewMarkerOnReportAction.ts | 3 - tests/unit/ReportActionsUtilsTest.ts | 164 +++++++++++++++++- tests/unit/useMarkAsReadTest.ts | 112 ++++++++++++ tests/unit/useUnreadMarkerTest.ts | 110 ++++++++++++ 4 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 tests/unit/useMarkAsReadTest.ts create mode 100644 tests/unit/useUnreadMarkerTest.ts diff --git a/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts b/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts index 5acd010e27a6..7f9106ced655 100644 --- a/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts +++ b/src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts @@ -76,9 +76,6 @@ const shouldDisplayNewMarkerOnReportAction = ({ const isPreviouslyOptimistic = (isPendingAdd(prevSortedVisibleReportActionsObjects[message.reportActionID]) && !isPendingAdd(message)) || (!!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction); - // The marker never lands on the user's own new or just-confirmed message. This intentionally drops the former - // `!prevUnreadMarkerReportActionID` gate, which let the marker fall on the user's own message in a rare corner - // (a prior marker on someone else's message became read, leaving only the user's own new message unread). const shouldIgnoreUnreadForCurrentUserMessage = isNewMessage || isPreviouslyOptimistic; if (isFromCurrentUser) { diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 0cc01f4a33b9..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'; @@ -5045,6 +5045,168 @@ describe('ReportActionsUtils', () => { }), ).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); + }); +});