From d63c643580fa42f01504424f67b7afbae24cda4c Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 8 Apr 2026 18:59:08 +0200 Subject: [PATCH 01/32] add useUnreadMarker hook --- src/hooks/useUnreadMarker.ts | 153 +++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/hooks/useUnreadMarker.ts diff --git a/src/hooks/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts new file mode 100644 index 000000000000..f4a3f3970581 --- /dev/null +++ b/src/hooks/useUnreadMarker.ts @@ -0,0 +1,153 @@ +import type {RefObject} from 'react'; +import {useEffect, useLayoutEffect, useRef, useState} from 'react'; +import {DeviceEventEmitter} from 'react-native'; +import {wasMessageReceivedWhileOffline} from '@libs/ReportActionsUtils'; +import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useIsAnonymousUser from './useIsAnonymousUser'; +import useLocalize from './useLocalize'; +import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; +import useOnyx from './useOnyx'; +import usePrevious from './usePrevious'; + +type UseUnreadMarkerParams = { + /** The report ID */ + reportID: string; + + /** Sorted actions that should be visible to the user */ + sortedVisibleReportActions: OnyxTypes.ReportAction[]; + + /** All sorted report actions (including hidden) — used for offline message index calculation */ + sortedReportActions: OnyxTypes.ReportAction[]; + + /** Ref to the current vertical scroll offset */ + scrollingVerticalOffset: RefObject; +}; + +type UseUnreadMarkerResult = { + /** The reportActionID that should display the unread marker above it */ + unreadMarkerReportActionID: string | null; + + /** The index within sortedVisibleReportActions of the action with the unread marker */ + unreadMarkerReportActionIndex: number; + + /** The timestamp used to determine which actions are unread */ + unreadMarkerTime: string; +}; + +const lastReadTimeSelector = (report: OnyxTypes.Report | undefined) => report?.lastReadTime ?? ''; + +function useUnreadMarker({reportID, sortedVisibleReportActions, sortedReportActions, scrollingVerticalOffset}: UseUnreadMarkerParams): UseUnreadMarkerResult { + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const isAnonymousUser = useIsAnonymousUser(); + const {getLocalDateFromDatetime} = useLocalize(); + const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); + + const [reportLastReadTime] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + selector: lastReadTimeSelector, + }); + + const lastReadTime = reportLastReadTime ?? ''; + + const [unreadMarkerTime, setUnreadMarkerTime] = useState(lastReadTime); + + // When reportID changes (handled by key={reportID} at the list level), reset unreadMarkerTime + // to the current lastReadTime for the new report. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + setUnreadMarkerTime(lastReadTime); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportID]); + + /** + * Subscribe to read/unread events and update 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); + + const lastAction = sortedVisibleReportActions.at(0); + + const prevUnreadMarkerReportActionID = useRef(null); + + /** + * The index of the earliest message that was received while offline. + * Reverse for-loop using .at() to find the last qualifying index. + */ + 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; + } + } + + const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = getUnreadMarkerReportAction({ + visibleReportActions: sortedVisibleReportActions, + earliestReceivedOfflineMessageIndex, + currentUserAccountID, + prevSortedVisibleReportActionsObjects, + unreadMarkerTime, + scrollingVerticalOffset: scrollingVerticalOffset.current ?? 0, + prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, + isOffline, + isReversed: false, + isAnonymousUser, + }); + prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; + + /** + * When the user reads a new message as it is received, push unreadMarkerTime down to the + * latest action's timestamp. When new actions arrive while the user is scrolled away + * (above MSG_VISIBLE_THRESHOLD), the marker displays over those new messages instead of + * sticking to 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]); + + return { + unreadMarkerReportActionID, + unreadMarkerReportActionIndex, + unreadMarkerTime, + }; +} + +export default useUnreadMarker; +export type {UseUnreadMarkerParams, UseUnreadMarkerResult}; From 01452ef099002393b4a1dfc8af74765336b4dcdf Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 8 Apr 2026 19:00:12 +0200 Subject: [PATCH 02/32] add useTransactionThread hook --- src/hooks/useTransactionThread.ts | 83 +++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/hooks/useTransactionThread.ts diff --git a/src/hooks/useTransactionThread.ts b/src/hooks/useTransactionThread.ts new file mode 100644 index 000000000000..67f450fde52c --- /dev/null +++ b/src/hooks/useTransactionThread.ts @@ -0,0 +1,83 @@ +import {useCallback} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getOneTransactionThreadReportID, getSortedReportActionsForDisplay} from '@libs/ReportActionsUtils'; +import {canUserPerformWriteAction, isReportTransactionThread as isReportTransactionThreadUtil} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import useOnyx from './useOnyx'; +import useReportIsArchived from './useReportIsArchived'; +import useReportTransactionsCollection from './useReportTransactionsCollection'; + +type UseTransactionThreadParams = { + reportID: string | undefined; + report: OnyxEntry; + allReportActions: ReportAction[]; + isOffline: boolean; +}; + +type UseTransactionThreadResult = { + isReportTransactionThread: boolean; + transactionThreadReportID: string | undefined; + transactionThreadReportActions: ReportAction[] | undefined; + transactionThreadReport: OnyxEntry; + parentReportActionForTransactionThread: ReportAction | undefined; +}; + +function selectTransactionThreadReportActions( + canPerformWriteAction: boolean, + transactionThreadReportID: string | undefined, + reportActions: OnyxEntry | undefined, +): ReportAction[] { + return getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true, undefined, transactionThreadReportID ?? undefined); +} + +function useTransactionThread({reportID, report, allReportActions, isOffline}: UseTransactionThreadParams): UseTransactionThreadResult { + const isReportTransactionThread = isReportTransactionThreadUtil(report); + + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + + const allReportTransactions = useReportTransactionsCollection(reportID); + + const reportTransactionsForThreadID = getAllNonDeletedTransactions(allReportTransactions, allReportActions ?? [], isOffline, true); + + const visibleTransactionsForThreadID = reportTransactionsForThreadID?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + + const reportTransactionIDsForThread = visibleTransactionsForThreadID?.map((t) => t.transactionID); + + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, allReportActions ?? [], isOffline, reportTransactionIDsForThread); + + const isReportArchived = useReportIsArchived(reportID); + const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); + + const getTransactionThreadReportActions = useCallback( + (reportActions: OnyxEntry): ReportAction[] => selectTransactionThreadReportActions(canPerformWriteAction, transactionThreadReportID, reportActions), + [canPerformWriteAction, transactionThreadReportID], + ); + + const [transactionThreadReportActions] = useOnyx( + `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + { + selector: getTransactionThreadReportActions, + }, + [getTransactionThreadReportActions], + ); + + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); + + const parentReportActionForTransactionThread = isEmptyObject(transactionThreadReportActions) + ? undefined + : allReportActions?.find((action) => action.reportActionID === transactionThreadReport?.parentReportActionID); + + return { + isReportTransactionThread, + transactionThreadReportID, + transactionThreadReportActions, + transactionThreadReport, + parentReportActionForTransactionThread, + }; +} + +export default useTransactionThread; From fad2679f48ed887b10d339d157717b46775b06fd Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 8 Apr 2026 19:01:31 +0200 Subject: [PATCH 03/32] add useMarkAsRead hook --- src/hooks/useMarkAsRead.ts | 149 +++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/hooks/useMarkAsRead.ts diff --git a/src/hooks/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts new file mode 100644 index 000000000000..aaad11b5b72b --- /dev/null +++ b/src/hooks/useMarkAsRead.ts @@ -0,0 +1,149 @@ +import {useIsFocused, useRoute} from '@react-navigation/native'; +import {useEffect, useRef, useState} from 'react'; +import type {RefObject} from 'react'; +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'; + +// 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; + +type UseMarkAsReadParams = { + reportID: string; + sortedVisibleReportActions: OnyxTypes.ReportAction[]; + transactionThreadReport: OnyxEntry; + scrollingVerticalOffset: RefObject; +}; + +type UseMarkAsReadResult = { + isVisible: boolean; + readActionSkippedRef: RefObject; +}; + +function useMarkAsRead({reportID, sortedVisibleReportActions, transactionThreadReport, scrollingVerticalOffset}: UseMarkAsReadParams): UseMarkAsReadResult { + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const route = useRoute>(); + const isFocused = useIsFocused(); + const isReportArchived = useReportIsArchived(reportID); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`); + + const [isVisible, setIsVisible] = useState(Visibility.isVisible); + + const readActionSkippedRef = useRef(false); + const userActiveSince = useRef(DateUtils.getDBTime()); + const lastMessageTime = useRef(null); + + const lastAction = sortedVisibleReportActions.at(0); + + useEffect(() => { + const unsubscribe = Visibility.onVisibilityChange(() => { + setIsVisible(Visibility.isVisible()); + }); + + return unsubscribe; + }, []); + + function handleReportChangeMarkAsRead() { + if (reportID !== prevReportID) { + return; + } + + if (isUnread(report, transactionThreadReport, isReportArchived) || (lastAction && isCurrentActionUnread(report, lastAction, sortedVisibleReportActions))) { + // 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; + if ((isVisible || isFromNotification) && scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { + readNewestAction(reportID, !!reportMetadata?.hasOnceLoadedReportActions); + if (isFromNotification) { + Navigation.setParams({referrer: undefined}); + } + return true; + } + + readActionSkippedRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + } + + function handleAppVisibilityMarkAsRead() { + if (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 = 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, !!reportMetadata?.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 + } + + const prevHandleReportChangeMarkAsRead = useRef<(() => boolean | undefined) | null>(null); + const prevHandleAppVisibilityMarkAsRead = useRef<(() => boolean | undefined) | null>(null); + + useEffect(() => { + let isMarkedAsRead = false; + if (handleReportChangeMarkAsRead !== prevHandleReportChangeMarkAsRead.current) { + isMarkedAsRead = !!handleReportChangeMarkAsRead(); + } + + if (!isMarkedAsRead && handleAppVisibilityMarkAsRead !== prevHandleAppVisibilityMarkAsRead.current) { + handleAppVisibilityMarkAsRead(); + } + + prevHandleReportChangeMarkAsRead.current = handleReportChangeMarkAsRead; + prevHandleAppVisibilityMarkAsRead.current = handleAppVisibilityMarkAsRead; + + prevReportID = reportID; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [report?.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, reportID, isVisible, isFocused, reportMetadata?.hasOnceLoadedReportActions]); + + return {isVisible, readActionSkippedRef}; +} + +export default useMarkAsRead; From 0270f063e842d90efb70bb72c6e34b95c62f7c3d Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 8 Apr 2026 19:05:17 +0200 Subject: [PATCH 04/32] add useReportActionsPagination, useReportActionsVisibility, modify useLoadReportActions --- .../MoneyRequestReportActionsList.tsx | 2 +- src/hooks/useLoadReportActions.ts | 20 ++-- src/hooks/useReportActionsPagination.ts | 55 ++++++++++ src/hooks/useReportActionsVisibility.ts | 101 ++++++++++++++++++ src/pages/inbox/report/ReportActionsView.tsx | 2 +- 5 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 src/hooks/useReportActionsPagination.ts create mode 100644 src/hooks/useReportActionsVisibility.ts diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index bf67d022d50b..c89e33f34120 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -396,7 +396,7 @@ function MoneyRequestReportActionsList({reportID: reportIDProp, onLayout}: Money reportID, reportActions, allReportActionIDs: reportActionIDs, - transactionThreadReport, + transactionThreadReportID: transactionThreadReport?.reportID, hasOlderActions, hasNewerActions, newestFetchedReportActionID: reportMetadata?.newestFetchedReportActionID, diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index d46e3a0031c2..630d74707c37 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -1,9 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; -import type {OnyxEntry} from 'react-native-onyx'; import {getNewerActions, getOlderActions} from '@userActions/Report'; import CONST from '@src/CONST'; -import type {Report, ReportAction} from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ReportAction} from '@src/types/onyx'; import useNetwork from './useNetwork'; type UseLoadReportActionsArguments = { @@ -16,8 +14,8 @@ type UseLoadReportActionsArguments = { /** The IDs of all reportActions linked to the current report (may contain some extra actions) */ allReportActionIDs: string[]; - /** The transaction thread report associated with the current transaction, if any */ - transactionThreadReport: OnyxEntry; + /** The transaction thread report ID associated with the current transaction, if any */ + transactionThreadReportID: string | undefined; /** If the report has newer actions to load */ hasNewerActions: boolean; @@ -37,7 +35,7 @@ function useLoadReportActions({ reportID, reportActions, allReportActionIDs, - transactionThreadReport, + transactionThreadReportID, hasOlderActions, hasNewerActions, newestFetchedReportActionID, @@ -47,7 +45,7 @@ function useLoadReportActions({ const newestReportAction = reportActions?.at(0); const oldestReportAction = reportActions?.at(-1); - const isTransactionThreadReport = !isEmptyObject(transactionThreadReport); + const isTransactionThreadReport = !!transactionThreadReportID; let currentReportNewestAction = null; let currentReportOldestAction = null; @@ -59,7 +57,7 @@ function useLoadReportActions({ for (const action of reportActions) { // Determine which report this action belongs to const isCurrentReport = allReportActionIDsSet.has(action.reportActionID); - const targetReportID = isCurrentReport ? reportID : transactionThreadReport?.reportID; + const targetReportID = isCurrentReport ? reportID : transactionThreadReportID; // Track newest/oldest per report if (targetReportID === reportID) { @@ -69,7 +67,7 @@ function useLoadReportActions({ } // Oldest = last matching action we encounter currentReportOldestAction = action; - } else if (isTransactionThreadReport && transactionThreadReport?.reportID === targetReportID) { + } else if (isTransactionThreadReport && transactionThreadReportID === targetReportID) { // Same logic for transaction thread if (!transactionThreadNewestAction) { transactionThreadNewestAction = action; @@ -95,7 +93,7 @@ function useLoadReportActions({ if (isTransactionThreadReport) { getOlderActions(reportID, currentReportOldestAction?.reportActionID); - getOlderActions(transactionThreadReport?.reportID, transactionThreadOldestAction?.reportActionID); + getOlderActions(transactionThreadReportID, transactionThreadOldestAction?.reportActionID); } else { getOlderActions(reportID, currentReportOldestAction?.reportActionID); } @@ -124,7 +122,7 @@ function useLoadReportActions({ if (isTransactionThreadReport) { getNewerActions(reportID, currentReportNewestAction?.reportActionID); - getNewerActions(transactionThreadReport.reportID, transactionThreadNewestAction?.reportActionID); + getNewerActions(transactionThreadReportID, transactionThreadNewestAction?.reportActionID); } else if (newestReportAction) { getNewerActions(reportID, newestReportAction.reportActionID); } diff --git a/src/hooks/useReportActionsPagination.ts b/src/hooks/useReportActionsPagination.ts new file mode 100644 index 000000000000..997d1c9af40c --- /dev/null +++ b/src/hooks/useReportActionsPagination.ts @@ -0,0 +1,55 @@ +import {useRoute} from '@react-navigation/native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; +import {getCombinedReportActions, getFilteredReportActionsForReportView} from '@libs/ReportActionsUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {Report, ReportAction} from '@src/types/onyx'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePaginatedReportActions from './usePaginatedReportActions'; +import useTransactionThread from './useTransactionThread'; + +type UseReportActionsPaginationResult = { + reportActions: ReportAction[]; + allReportActions: ReportAction[]; + allReportActionIDs: string[]; + hasOlderActions: boolean; + hasNewerActions: boolean; + reportActionID: string | undefined; + transactionThreadReport: OnyxEntry; + parentReportActionForTransactionThread: ReportAction | undefined; + isReportTransactionThread: boolean; +}; + +function useReportActionsPagination(reportID: string | undefined): UseReportActionsPaginationResult { + const route = useRoute>(); + const reportActionID = route?.params?.reportActionID; + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const {isOffline} = useNetwork(); + + const {reportActions: unfilteredReportActions, hasOlderActions, hasNewerActions} = usePaginatedReportActions(reportID, reportActionID); + const allReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + const thread = useTransactionThread({reportID, report, allReportActions, isOffline}); + + const reportActions = getCombinedReportActions(allReportActions, thread.transactionThreadReportID ?? null, thread.transactionThreadReportActions ?? []); + + const allReportActionIDs = allReportActions.map((action) => action.reportActionID); + + return { + reportActions, + allReportActions, + allReportActionIDs, + hasOlderActions, + hasNewerActions, + reportActionID, + transactionThreadReport: thread.transactionThreadReport, + parentReportActionForTransactionThread: thread.parentReportActionForTransactionThread, + isReportTransactionThread: thread.isReportTransactionThread, + }; +} + +export default useReportActionsPagination; diff --git a/src/hooks/useReportActionsVisibility.ts b/src/hooks/useReportActionsVisibility.ts new file mode 100644 index 000000000000..c77e41d33f6a --- /dev/null +++ b/src/hooks/useReportActionsVisibility.ts @@ -0,0 +1,101 @@ +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getMostRecentIOURequestActionID, isCreatedAction, isDeletedParentAction, isIOUActionMatchingTransactionList, isReportActionVisible} from '@libs/ReportActionsUtils'; +import {isConciergeChatReport} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; +import useConciergeSidePanelReportActions from './useConciergeSidePanelReportActions'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useIsInSidePanel from './useIsInSidePanel'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import useSidePanelState from './useSidePanelState'; +import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; + +type UseReportActionsVisibilityResult = { + visibleReportActions: ReportAction[]; + mostRecentIOUReportActionID: string | null; + isOffline: boolean; + isConciergeSidePanel: boolean; + hasPreviousMessages: boolean; + showConciergeSidePanelWelcome: boolean; + showFullHistory: boolean; + handleShowPreviousMessages: () => void; +}; + +function useReportActionsVisibility( + reportID: string | undefined, + reportActions: ReportAction[], + canPerformWriteAction: boolean, + hasOlderActions: boolean, + loadOlderChats: (force?: boolean) => void, +): UseReportActionsVisibilityResult { + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); + + const {transactions} = useTransactionsAndViolationsForReport(reportID); + const reportTransactionIDs = getAllNonDeletedTransactions(transactions, reportActions).map((t) => t.transactionID); + + const baseFilteredActions = reportActions.filter((reportAction) => { + const passesOfflineCheck = isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors; + + if (!passesOfflineCheck) { + return false; + } + + const actionReportID = reportAction.reportID ?? reportID; + if (!isReportActionVisible(reportAction, actionReportID, canPerformWriteAction, visibleReportActionsData)) { + return false; + } + + if (!isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs)) { + return false; + } + + return true; + }); + + const isInSidePanel = useIsInSidePanel(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const isConciergeSidePanel = isInSidePanel && isConciergeChatReport(report, conciergeReportID); + + const {sessionStartTime} = useSidePanelState(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + + const hasUserSentMessage = + isConciergeSidePanel && sessionStartTime + ? reportActions.some((action) => !isCreatedAction(action) && action.actorAccountID === currentUserAccountID && action.created >= sessionStartTime) + : false; + + const concierge = useConciergeSidePanelReportActions({ + report, + reportActions, + visibleReportActions: baseFilteredActions, + isConciergeSidePanel, + hasUserSentMessage, + hasOlderActions, + sessionStartTime, + currentUserAccountID, + greetingText: translate('common.concierge.sidePanelGreeting'), + loadOlderChats, + }); + + const visibleReportActions = isConciergeSidePanel ? concierge.filteredVisibleActions : baseFilteredActions; + + return { + visibleReportActions, + mostRecentIOUReportActionID: getMostRecentIOURequestActionID(reportActions), + isOffline, + isConciergeSidePanel, + hasPreviousMessages: concierge.hasPreviousMessages, + showConciergeSidePanelWelcome: concierge.showConciergeSidePanelWelcome, + showFullHistory: concierge.showFullHistory, + handleShowPreviousMessages: concierge.handleShowPreviousMessages, + }; +} + +export default useReportActionsVisibility; diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 8f4ce82ebfd6..b2efe5a4c21a 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -296,7 +296,7 @@ function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { reportID, reportActions, allReportActionIDs, - transactionThreadReport, + transactionThreadReportID: transactionThreadReport?.reportID, hasOlderActions, hasNewerActions, }); From 1a65989bf68ef036661b835bd5c2ba0a0c432b97 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 8 Apr 2026 19:10:19 +0200 Subject: [PATCH 05/32] add useReportActionsScroll hook --- src/hooks/useReportActionsScroll.ts | 340 ++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 src/hooks/useReportActionsScroll.ts diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts new file mode 100644 index 000000000000..e1b430d19d32 --- /dev/null +++ b/src/hooks/useReportActionsScroll.ts @@ -0,0 +1,340 @@ +import {useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; +import {InteractionManager} from 'react-native'; +import {isSafari} from '@libs/Browser'; +import durationHighlightItem from '@libs/Navigation/helpers/getDurationHighlightItem'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import {isReportPreviewAction} from '@libs/ReportActionsUtils'; +import {getReportLastVisibleActionCreated} from '@libs/ReportUtils'; +import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; +import {ActionListContext} from '@pages/inbox/ReportScreenContext'; +import {openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import useOnyx from './useOnyx'; +import useReportScrollManager from './useReportScrollManager'; +import useScrollToEndOnNewMessageReceived from './useScrollToEndOnNewMessageReceived'; + +// In the component we are subscribing to the arrival of new actions. +// As there is the possibility that there are multiple instances of a ReportScreen +// for the same report, we only ever want one subscription to be active, as +// the subscriptions could otherwise be conflicting. +const newActionUnsubscribeMap: Record void> = {}; + +type UseReportActionsScrollParams = { + reportID: string; + report: OnyxTypes.Report; + sortedVisibleReportActions: OnyxTypes.ReportAction[]; + readActionSkippedRef: React.RefObject; + unreadMarkerReportActionIndex: number; + loadOlderChats: (force?: boolean) => void; + loadNewerChats: (force?: boolean) => void; + shouldAddCreatedAction: boolean; + linkedReportActionID: string | undefined; + shouldScrollToEndAfterLayout: boolean; + setShouldScrollToEndAfterLayout: (v: boolean) => void; + shouldFocusToTopOnMount: boolean; + isOffline: boolean; + hasOnceLoadedReportActions: boolean; + onLayout?: (event: LayoutChangeEvent) => void; +}; + +type UseReportActionsScrollResult = { + reportScrollManager: ReturnType; + trackVerticalScrolling: (event: NativeSyntheticEvent | undefined) => void; + onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + isFloatingMessageCounterVisible: boolean; + scrollToBottomAndMarkReportAsRead: () => void; + onStartReached: () => void; + onEndReached: () => void; + onLayoutInner: (event: LayoutChangeEvent) => void; + retryLoadNewerChatsError: () => void; + actionIdToHighlight: string; +}; + +function useReportActionsScroll({ + reportID, + report, + sortedVisibleReportActions, + readActionSkippedRef, + unreadMarkerReportActionIndex, + loadOlderChats, + loadNewerChats, + shouldAddCreatedAction, + linkedReportActionID, + shouldScrollToEndAfterLayout, + setShouldScrollToEndAfterLayout, + shouldFocusToTopOnMount, + isOffline, + hasOnceLoadedReportActions, + onLayout, +}: UseReportActionsScrollParams): UseReportActionsScrollResult { + const {scrollOffsetRef} = useContext(ActionListContext); + const reportScrollManager = useReportScrollManager(); + + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [betas] = useOnyx(ONYXKEYS.BETAS); + + const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); + const [actionIdToHighlight, setActionIdToHighlight] = useState(''); + + const prevShouldAddCreatedAction = useRef(shouldAddCreatedAction); + const hasNewestReportActionRef = useRef(false); + const sortedVisibleReportActionsRef = useRef(sortedVisibleReportActions); + + const lastAction = sortedVisibleReportActions.at(0); + const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, undefined); + const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated || isReportPreviewAction(lastAction); + hasNewestReportActionRef.current = hasNewestReportAction; + sortedVisibleReportActionsRef.current = sortedVisibleReportActions; + + const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: scrollOffsetRef, + readActionSkippedRef, + unreadMarkerReportActionIndex, + isInverted: true, + onTrackScrolling: (event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + if (shouldScrollToEndAfterLayout && (!shouldAddCreatedAction || isOffline)) { + setShouldScrollToEndAfterLayout(false); + } + }, + hasOnceLoadedReportActions, + }); + + useScrollToEndOnNewMessageReceived({ + sizeChangeType: 'changed', + scrollOffsetRef, + lastActionID: lastAction?.reportActionID, + visibleActionsLength: sortedVisibleReportActions.length, + hasNewestReportAction, + setIsFloatingMessageCounterVisible, + scrollToEnd: reportScrollManager.scrollToBottom, + resetKey: linkedReportActionID, + }); + + // Scroll to end when shouldAddCreatedAction flips off (IOU/transaction thread top focus) + useEffect(() => { + const shouldTriggerScroll = shouldFocusToTopOnMount && prevShouldAddCreatedAction.current && !shouldAddCreatedAction; + if (!shouldTriggerScroll) { + return; + } + requestAnimationFrame(() => reportScrollManager.scrollToEnd()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldAddCreatedAction]); + + useEffect(() => { + prevShouldAddCreatedAction.current = shouldAddCreatedAction; + }, [shouldAddCreatedAction]); + + // scrollToBottomForCurrentUserAction — called from Pusher new action subscription + const scrollToBottomForCurrentUserAction = (isFromCurrentUser: boolean, action?: OnyxTypes.ReportAction) => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where + // they are now in the list. + if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { + return; + } + if (!hasNewestReportActionRef.current && !isFromCurrentUser) { + if (Navigation.getReportRHPActiveRoute()) { + return; + } + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + }); + return; + } + const index = sortedVisibleReportActionsRef.current.findIndex((item) => item.reportActionID === action?.reportActionID); + if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { + if (index > 0) { + setTimeout(() => { + reportScrollManager.scrollToIndex(index); + }, 100); + } else { + setIsFloatingMessageCounterVisible(false); + reportScrollManager.scrollToBottom(); + } + if (action?.reportActionID) { + setActionIdToHighlight(action.reportActionID); + } + } else { + setIsFloatingMessageCounterVisible(false); + reportScrollManager.scrollToBottom(); + } + + setIsScrollToBottomEnabled(true); + }); + }; + + // Pusher new action subscription — one per reportID + useEffect(() => { + // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? + // Answer: On web, when navigating to another report screen, the previous report screen doesn't get unmounted, + // meaning that the cleanup might not get called. When we then open a report we had open already previously, a new + // ReportScreen will get created. Thus, we have to cancel the earlier subscription of the previous screen, + // because the two subscriptions could conflict! + // In case we return to the previous screen (e.g. by web back navigation) the useEffect for that screen would + // fire again, as the focus has changed and will set up the subscription correctly again. + const previousSubUnsubscribe = newActionUnsubscribeMap[reportID]; + if (previousSubUnsubscribe) { + previousSubUnsubscribe(); + } + + // This callback is triggered when a new action arrives via Pusher and the event is emitted from Report.js. This allows us to maintain + // a single source of truth for the "new action" event instead of trying to derive that a new action has appeared from looking at props. + const unsubscribe = subscribeToNewActionEvent(reportID, scrollToBottomForCurrentUserAction); + + const cleanup = () => { + if (!unsubscribe) { + return; + } + unsubscribe(); + }; + + newActionUnsubscribeMap[reportID] = cleanup; + + return cleanup; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportID]); + + // Clear highlight timer after scrolling and highlighting + useEffect(() => { + if (actionIdToHighlight === '') { + return; + } + // Time highlight is the same as SearchPage + const timer = setTimeout(() => { + setActionIdToHighlight(''); + }, durationHighlightItem); + return () => clearTimeout(timer); + }, [actionIdToHighlight]); + + // IOU error scroll — scroll to bottom when a new IOU error appears + const lastIOUActionWithError = sortedVisibleReportActions.find((action) => action.errors); + const prevLastIOUActionWithErrorID = useRef(lastIOUActionWithError?.reportActionID); + + useEffect(() => { + if (lastIOUActionWithError?.reportActionID === prevLastIOUActionWithErrorID.current) { + return; + } + prevLastIOUActionWithErrorID.current = lastIOUActionWithError?.reportActionID; + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + reportScrollManager.scrollToBottom(); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastAction]); + + // Initial scroll to bottom on mount + useEffect(() => { + if (linkedReportActionID) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + if (shouldScrollToEndAfterLayout) { + return; + } + setIsFloatingMessageCounterVisible(false); + reportScrollManager.scrollToBottom(); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Safari whisper scroll fix + // https://github.com/Expensify/App/issues/54520 + const prevSortedVisibleReportActionsObjects = useRef>({}); + + useLayoutEffect(() => { + if (!isSafari()) { + return; + } + const prevSorted = lastAction?.reportActionID ? prevSortedVisibleReportActionsObjects.current[lastAction.reportActionID] : null; + if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER && !prevSorted) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + reportScrollManager.scrollToBottom(); + }); + } + }, [lastAction?.reportActionID, lastAction?.actionName, reportScrollManager]); + + useEffect(() => { + prevSortedVisibleReportActionsObjects.current = sortedVisibleReportActions.reduce>((actions, action) => { + // eslint-disable-next-line no-param-reassign + actions[action.reportActionID] = action; + return actions; + }, {}); + }, [sortedVisibleReportActions]); + + const scrollToBottomAndMarkReportAsRead = () => { + setIsFloatingMessageCounterVisible(false); + + if (!hasNewestReportAction) { + if (!Navigation.getReportRHPActiveRoute()) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + } + openReport({reportID, introSelected, betas}); + reportScrollManager.scrollToBottom(); + return; + } + reportScrollManager.scrollToBottom(); + // eslint-disable-next-line no-param-reassign + readActionSkippedRef.current = false; + readNewestAction(reportID, hasOnceLoadedReportActions); + }; + + const onStartReached = () => { + if (!isSearchTopmostFullScreenRoute()) { + loadNewerChats(false); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => requestAnimationFrame(() => loadNewerChats(false))); + }; + + const onEndReached = () => { + loadOlderChats(false); + }; + + const onLayoutInner = (event: LayoutChangeEvent) => { + onLayout?.(event); + if (isScrollToBottomEnabled) { + reportScrollManager.scrollToBottom(); + setIsScrollToBottomEnabled(false); + } + if (shouldScrollToEndAfterLayout && (!shouldAddCreatedAction || isOffline)) { + requestAnimationFrame(() => { + reportScrollManager.scrollToEnd(); + }); + } + }; + + const retryLoadNewerChatsError = () => { + loadNewerChats(true); + }; + + return { + reportScrollManager, + trackVerticalScrolling, + onViewableItemsChanged, + isFloatingMessageCounterVisible, + scrollToBottomAndMarkReportAsRead, + onStartReached, + onEndReached, + onLayoutInner, + retryLoadNewerChatsError, + actionIdToHighlight, + }; +} + +export default useReportActionsScroll; +export type {UseReportActionsScrollParams, UseReportActionsScrollResult}; From ebd5cb93241e87badec5664a55a777d48a332ced Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 8 Apr 2026 19:12:02 +0200 Subject: [PATCH 06/32] add ReportActionsListHeader and ShowPreviousMessagesButton --- .../inbox/report/ReportActionsListHeader.tsx | 35 ++++++++++++ .../report/ShowPreviousMessagesButton.tsx | 56 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/pages/inbox/report/ReportActionsListHeader.tsx create mode 100644 src/pages/inbox/report/ShowPreviousMessagesButton.tsx diff --git a/src/pages/inbox/report/ReportActionsListHeader.tsx b/src/pages/inbox/report/ReportActionsListHeader.tsx new file mode 100644 index 000000000000..ac8698159d26 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsListHeader.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import useIsInSidePanel from '@hooks/useIsInSidePanel'; +import useOnyx from '@hooks/useOnyx'; +import {isConciergeChatReport} from '@libs/ReportUtils'; +import ConciergeThinkingMessage from '@pages/home/report/ConciergeThinkingMessage'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ListBoundaryLoader from './ListBoundaryLoader'; + +type ReportActionsListHeaderProps = { + /** The ID of the report being displayed */ + reportID: string; + + /** Callback to retry loading newer chats after an error */ + onRetry: () => void; +}; + +function ReportActionsListHeader({reportID, onRetry}: ReportActionsListHeaderProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const isInSidePanel = useIsInSidePanel(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const isConciergeSidePanel = isInSidePanel && isConciergeChatReport(report, conciergeReportID); + + return ( + <> + {isConciergeSidePanel && } + + + ); +} + +export default ReportActionsListHeader; diff --git a/src/pages/inbox/report/ShowPreviousMessagesButton.tsx b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx new file mode 100644 index 000000000000..5a0c60a88d23 --- /dev/null +++ b/src/pages/inbox/report/ShowPreviousMessagesButton.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +type ShowPreviousMessagesButtonProps = { + /** The report action being rendered for this list item */ + reportAction: ReportAction; + + /** Whether there are previous messages hidden before the session start */ + hasPreviousMessages: boolean; + + /** Whether the full message history is currently shown */ + showFullHistory: boolean; + + /** Callback to reveal the full message history */ + onPress: () => void; +}; + +function ShowPreviousMessagesButton({reportAction, hasPreviousMessages, showFullHistory, onPress}: ShowPreviousMessagesButtonProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow']); + + if (reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { + return null; + } + if (!hasPreviousMessages) { + return null; + } + if (showFullHistory) { + return null; + } + + return ( + + + +