diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx index 44444f908220..b6bad42fd60d 100644 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ b/src/components/FlashList/InvertedFlashList/index.tsx @@ -1,9 +1,10 @@ -import type {FlashListProps} from '@shopify/flash-list'; -import React from 'react'; +import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; +import React, {useRef} from 'react'; import useFlashListScrollKey from '@components/FlashList/useFlashListScrollKey'; import type {FlatListRefType} from '@pages/inbox/ReportScreenContext'; import FlashList from '..'; import CellRendererComponent from './CellRendererComponent'; +import useInvertedWheelHandler from './useInvertedWheelHandler'; type InvertedFlashListProps = FlashListProps & { /** Key of the item to initially scroll to when the list first renders. */ @@ -19,7 +20,7 @@ type InvertedFlashListProps = FlashListProps & { ref: FlatListRefType; }; -function InvertedFlashList({data, keyExtractor, initialScrollKey, onStartReached: onStartReachedProp, ...restProps}: InvertedFlashListProps) { +function InvertedFlashList({ref, data, keyExtractor, initialScrollKey, onStartReached: onStartReachedProp, ...restProps}: InvertedFlashListProps) { const {displayedData, onStartReached} = useFlashListScrollKey({ data, keyExtractor, @@ -27,11 +28,23 @@ function InvertedFlashList({data, keyExtractor, initialScrollKey, onStartReac onStartReached: onStartReachedProp, }); + const innerRef = useRef | null>(null); + useInvertedWheelHandler(innerRef); + + const composedRef = (node: FlashListRef | null) => { + innerRef.current = node; + if (ref) { + const userRef = ref as unknown as React.MutableRefObject | null>; + userRef.current = node; + } + }; + return ( // eslint-disable-next-line react/jsx-props-no-spreading {...restProps} inverted + ref={composedRef} onStartReached={onStartReached} data={displayedData} keyExtractor={keyExtractor} diff --git a/src/components/FlashList/InvertedFlashList/useInvertedWheelHandler.ts b/src/components/FlashList/InvertedFlashList/useInvertedWheelHandler.ts new file mode 100644 index 000000000000..c4a6c23210dd --- /dev/null +++ b/src/components/FlashList/InvertedFlashList/useInvertedWheelHandler.ts @@ -0,0 +1,8 @@ +import type {FlashListRef} from '@shopify/flash-list'; + +// No-op on native. Inverted wheel handling is only needed on web where the +// inverted `scaleY: -1` transform reverses wheel direction. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function useInvertedWheelHandler(ref: React.RefObject | null>) {} + +export default useInvertedWheelHandler; diff --git a/src/components/FlashList/InvertedFlashList/useInvertedWheelHandler.web.ts b/src/components/FlashList/InvertedFlashList/useInvertedWheelHandler.web.ts new file mode 100644 index 000000000000..420d1474ee21 --- /dev/null +++ b/src/components/FlashList/InvertedFlashList/useInvertedWheelHandler.web.ts @@ -0,0 +1,36 @@ +import type {FlashListRef} from '@shopify/flash-list'; +import {useEffect} from 'react'; + +// FlashList uses its own RecyclerView on web and bypasses react-native-web's +// `invertedWheelEventHandler` patch (VirtualizedList/index.js:702). That patch flips +// wheel delta to compensate for the `scaleY: -1` transform applied by `inverted`. +// Without it, wheel scroll feels reversed: wheel down reveals older messages +// instead of newer. This hook restores parity by intercepting wheel events on the +// scrollable node and translating them into an inverted scrollTop adjustment. +function useInvertedWheelHandler(ref: React.RefObject | null>) { + useEffect(() => { + const node = ref.current?.getScrollableNode?.() as HTMLElement | undefined; + if (!node) { + return; + } + + const handler = (ev: WheelEvent) => { + const deltaY = ev.deltaY; + if (!deltaY) { + return; + } + + const maxScrollTop = Math.max(0, node.scrollHeight - node.clientHeight); + const nextScrollTop = node.scrollTop - deltaY; + node.scrollTop = Math.max(0, Math.min(nextScrollTop, maxScrollTop)); + + ev.preventDefault(); + ev.stopPropagation(); + }; + + node.addEventListener('wheel', handler, {passive: false}); + return () => node.removeEventListener('wheel', handler); + }, [ref]); +} + +export default useInvertedWheelHandler; diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx index 888821e8d1a3..598033f4ca90 100644 --- a/src/components/FlashList/index.tsx +++ b/src/components/FlashList/index.tsx @@ -1,10 +1,12 @@ import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; -import type {FlashListProps} from '@shopify/flash-list'; +import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; import React from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; -function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps) { +type FlashListPropsWithRef = FlashListProps & {ref?: React.Ref>}; + +function FlashList({ref, onScroll: onScrollProp, inverted, ...restProps}: FlashListPropsWithRef) { const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); const handleScroll = (e: NativeSyntheticEvent) => { @@ -17,6 +19,7 @@ function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashLis // eslint-disable-next-line react/jsx-props-no-spreading {...restProps} + ref={ref} inverted={inverted} onScroll={handleScroll} /> diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index ded3a7d9f439..a282d50c8466 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -4,7 +4,7 @@ import {isUserValidatedSelector} from '@selectors/Account'; import {tierNameSelector} from '@selectors/UserWallet'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import type {FlatList, LayoutChangeEvent, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -205,11 +205,16 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const lastAction = visibleReportActions.at(-1); - const {scrollOffsetRef} = useContext(ActionListContext); + const {scrollOffsetRef, registerListRef} = useContext(ActionListContext); const scrollingVerticalBottomOffset = useRef(0); const scrollingVerticalTopOffset = useRef(0); const wrapperViewRef = useRef(null); const readActionSkipped = useRef(false); + const listRef = useRef | null>(null); + useEffect(() => { + registerListRef(listRef); + return () => registerListRef(null); + }, [registerListRef]); const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated; const userActiveSince = useRef(DateUtils.getDBTime()); @@ -222,7 +227,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) reportID, reportActions, allReportActionIDs: reportActionIDs, - transactionThreadReport, + transactionThreadReportID: transactionThreadReport?.reportID, hasOlderActions, hasNewerActions, newestFetchedReportActionID: reportMetadata?.newestFetchedReportActionID, @@ -309,8 +314,6 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) loadNewerChats(false); }, [loadNewerChats]); - const prevUnreadMarkerReportActionID = useRef(null); - const visibleActionsMap = useMemo(() => { return visibleReportActions.reduce((actionsMap, reportAction) => { Object.assign(actionsMap, { @@ -422,11 +425,9 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) prevSortedVisibleReportActionsObjects: prevVisibleActionsMap, unreadMarkerTime, scrollingVerticalOffset: scrollingVerticalBottomOffset.current, - prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, isOffline, isReversed: true, }); - prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ reportID: report.reportID, @@ -730,7 +731,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) keyboardShouldPersistTaps="handled" onScroll={trackVerticalScrolling} contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]} - ref={reportScrollManager.ref} + ref={listRef} ListEmptyComponent={!isOffline && showReportActionsLoadingState ? : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState removeClippedSubviews={false} initialScrollKey={linkedReportActionID} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index d844c7c5ac62..0262489b6f94 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -30,7 +30,7 @@ import {cancelSpan} from '@libs/telemetry/activeSpans'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import Navigation from '@navigation/Navigation'; -import ReportActionsView from '@pages/inbox/report/ReportActionsView'; +import ReportActionsList from '@pages/inbox/report/ReportActionsList'; import ReportFooter from '@pages/inbox/report/ReportFooter'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -149,7 +149,7 @@ function MoneyRequestReportView({report, reportMetadata, shouldDisplayReportFoot }, [reportID]); // Special case handling a report that is a transaction thread - // If true we will use standard `ReportActionsView` to display report data and a special header, anything else is handled via `MoneyRequestReportActionsList` + // If true we will use standard `ReportActionsList` to display report data and a special header, anything else is handled via `MoneyRequestReportActionsList` const isTransactionThreadView = isReportTransactionThread(report); // Prevent the empty state flash by ensuring transaction data is fully loaded before deciding which view to render @@ -275,8 +275,8 @@ function MoneyRequestReportView({report, reportMetadata, shouldDisplayReportFoot {shouldDisplayMoneyRequestActionsList ? ( ) : ( - )} diff --git a/src/hooks/useActionListContextValue.ts b/src/hooks/useActionListContextValue.ts index ba73ff804cc0..1441c7722723 100644 --- a/src/hooks/useActionListContextValue.ts +++ b/src/hooks/useActionListContextValue.ts @@ -1,13 +1,23 @@ import {useRef} from 'react'; -import type {FlatList} from 'react-native'; +import type {RefObject} from 'react'; import type {ActionListContextType, ScrollPosition} from '@pages/inbox/ReportScreenContext'; function useActionListContextValue(): ActionListContextType { - const flatListRef = useRef(null); + // Private holder — the registered list ref from whichever child list is currently mounted. + // Owning the ref here (instead of exposing a RefObject through context) lets React Compiler + // optimize descendants that previously had to pass a context-owned ref to JSX `ref={}`. + const listRefHolder = useRef | null>(null); const scrollPositionRef = useRef({}); const scrollOffsetRef = useRef(0); - return {flatListRef, scrollPositionRef, scrollOffsetRef}; + const registerListRef = (ref: RefObject | null) => { + listRefHolder.current = ref; + }; + // Caller-side types vary (FlashListRef | FlatList). We expose the duck-typed imperative + // interface here so scroll handlers can invoke scrollTo* without per-caller casts. + const getListRef: ActionListContextType['getListRef'] = () => listRefHolder.current as ReturnType; + + return {registerListRef, getListRef, scrollPositionRef, scrollOffsetRef}; } export default useActionListContextValue; 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/useMarkAsRead.ts b/src/hooks/useMarkAsRead.ts new file mode 100644 index 000000000000..d94561e457d0 --- /dev/null +++ b/src/hooks/useMarkAsRead.ts @@ -0,0 +1,134 @@ +import {useIsFocused, useRoute} from '@react-navigation/native'; +import {useEffect, useEffectEvent, useRef, useSyncExternalStore} from 'react'; +import type {RefObject} 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'; + +// 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 = { + 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 = useSyncExternalStore(Visibility.onVisibilityChange, Visibility.isVisible); + + const readActionSkippedRef = useRef(false); + const userActiveSince = useRef(DateUtils.getDBTime()); + const lastMessageTime = useRef(null); + + const lastAction = sortedVisibleReportActions.at(0); + + useEffect(() => { + const subscription = DeviceEventEmitter.addListener(`unreadAction_${reportID}`, () => { + userActiveSince.current = DateUtils.getDBTime(); + }); + return () => subscription.remove(); + }, [reportID]); + + const handleReportChangeMarkAsRead = useEffectEvent(() => { + if (reportID !== prevReportID) { + return false; + } + + 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; + } + return false; + }); + + // 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. + const handleAppVisibilityMarkAsRead = useEffectEvent(() => { + 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(); + }); + + useEffect(() => { + // handleReportChangeMarkAsRead short-circuits via isMarkedAsRead to prevent duplicate readNewestAction calls. + const isMarkedAsRead = handleReportChangeMarkAsRead(); + if (!isMarkedAsRead) { + handleAppVisibilityMarkAsRead(); + } + prevReportID = reportID; + }, [report?.lastVisibleActionCreated, transactionThreadReport?.lastVisibleActionCreated, reportID, isVisible, isFocused, reportMetadata?.hasOnceLoadedReportActions]); + + return {readActionSkippedRef}; +} + +export default useMarkAsRead; diff --git a/src/hooks/useReportActionsPagination.ts b/src/hooks/useReportActionsPagination.ts new file mode 100644 index 000000000000..ac7d4bbd02e5 --- /dev/null +++ b/src/hooks/useReportActionsPagination.ts @@ -0,0 +1,136 @@ +import {useRoute} from '@react-navigation/native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {getReportPreviewAction} from '@libs/actions/IOU'; +import DateUtils from '@libs/DateUtils'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; +import {rand64} from '@libs/NumberUtils'; +import {getCombinedReportActions, getFilteredReportActionsForReportView, getOriginalMessage, isCreatedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import { + buildOptimisticCreatedReportAction, + buildOptimisticIOUReportAction, + isConciergeChatReport, + isInvoiceReport, + isMoneyRequestReport, + isReportTransactionThread as isReportTransactionThreadUtil, +} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {Report, ReportAction} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import useIsInSidePanel from './useIsInSidePanel'; +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; + shouldAddCreatedAction: 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 isInSidePanel = useIsInSidePanel(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const isConciergeSidePanel = isInSidePanel && isConciergeChatReport(report, conciergeReportID); + + const isReportTransactionThread = isReportTransactionThreadUtil(report); + const isInitiallyLoadingTransactionThread = isReportTransactionThread && (allReportActions ?? [])?.length <= 1; + + const lastAction = allReportActions?.at(-1); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const shouldAddCreatedAction = !isCreatedAction(lastAction) && (isMoneyRequestReport(report) || isInvoiceReport(report) || isInitiallyLoadingTransactionThread || isConciergeSidePanel); + + const reportPreviewAction = getReportPreviewAction(report?.chatReportID, report?.reportID); + + // When we are offline before opening an IOU/Expense report, + // the total of the report and sometimes the expense aren't displayed because these actions aren't returned until `OpenReport` API is complete. + // We generate a fake created action here if it doesn't exist to display the total whenever possible because the total just depends on report data + // and we also generate an expense action if the number of expenses in allReportActions is less than the total number of expenses + // to display at least one expense action to match the total data. + let reportActionsToDisplay: ReportAction[]; + const actions = [...(allReportActions ?? [])]; + + if (shouldAddCreatedAction) { + const createdTime = lastAction?.created && DateUtils.subtractMillisecondsFromDateTime(lastAction.created, 1); + const optimisticCreatedAction = buildOptimisticCreatedReportAction(String(report?.ownerAccountID), createdTime); + optimisticCreatedAction.pendingAction = null; + actions.push(optimisticCreatedAction); + } + + if (!isMoneyRequestReport(report) || !allReportActions?.length) { + reportActionsToDisplay = actions; + } else { + const moneyRequestActions = allReportActions.filter((action) => { + const originalMessage = isMoneyRequestAction(action) ? getOriginalMessage(action) : undefined; + return ( + isMoneyRequestAction(action) && + originalMessage && + (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || + !!(originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && originalMessage?.IOUDetails) || + originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK) + ); + }); + + if (report?.total && moneyRequestActions.length < (reportPreviewAction?.childMoneyRequestCount ?? 0) && isEmptyObject(thread.transactionThreadReport)) { + const optimisticIOUAction = buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 0, + currency: CONST.CURRENCY.USD, + comment: '', + participants: [], + transactionID: rand64(), + iouReportID: report?.reportID, + created: DateUtils.subtractMillisecondsFromDateTime(actions.at(-1)?.created ?? '', 1), + }) as ReportAction; + moneyRequestActions.push(optimisticIOUAction); + actions.splice(actions.length - 1, 0, optimisticIOUAction); + } + + // Update pending action of created action if we have some requests that are pending + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const createdAction = actions.pop()!; + if (moneyRequestActions.filter((action) => !!action.pendingAction).length > 0) { + createdAction.pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; + } + + reportActionsToDisplay = [...actions, createdAction]; + } + + const reportActions = getCombinedReportActions(reportActionsToDisplay, 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, + shouldAddCreatedAction, + }; +} + +export default useReportActionsPagination; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts new file mode 100644 index 000000000000..bed33d9d0d3b --- /dev/null +++ b/src/hooks/useReportActionsScroll.ts @@ -0,0 +1,287 @@ +import {useRoute} from '@react-navigation/native'; +import {useContext, useEffect, useEffectEvent, useLayoutEffect, useRef, useState} from 'react'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; +import {InteractionManager} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +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: OnyxEntry; + sortedVisibleReportActions: OnyxTypes.ReportAction[]; + readActionSkippedRef: React.RefObject; + unreadMarkerReportActionIndex: number; + loadOlderChats: (force?: boolean) => void; + loadNewerChats: (force?: boolean) => void; + linkedReportActionID: string | undefined; + hasOnceLoadedReportActions: boolean; + onLayout?: (event: LayoutChangeEvent) => void; +}; + +type UseReportActionsScrollResult = { + 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, + linkedReportActionID, + hasOnceLoadedReportActions, + onLayout, +}: UseReportActionsScrollParams): UseReportActionsScrollResult { + const route = useRoute(); + const backTo = (route?.params as {backTo?: string} | undefined)?.backTo; + + 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 lastAction = sortedVisibleReportActions.at(0); + const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, undefined); + const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated || isReportPreviewAction(lastAction); + + const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: scrollOffsetRef, + readActionSkippedRef, + unreadMarkerReportActionIndex, + isInverted: true, + onTrackScrolling: (event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, + hasOnceLoadedReportActions, + }); + + useScrollToEndOnNewMessageReceived({ + sizeChangeType: 'changed', + scrollOffsetRef, + lastActionID: lastAction?.reportActionID, + visibleActionsLength: sortedVisibleReportActions.length, + hasNewestReportAction, + setIsFloatingMessageCounterVisible, + scrollToEnd: reportScrollManager.scrollToBottom, + resetKey: linkedReportActionID, + }); + + // scrollToBottomForCurrentUserAction — called from Pusher new action subscription. + // useEffectEvent keeps the callback identity stable so the subscription isn't re-registered per render, + // while still reading the latest `hasNewestReportAction` / `sortedVisibleReportActions` on invocation. + const scrollToBottomForCurrentUserAction = useEffectEvent((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 (!hasNewestReportAction && !isFromCurrentUser) { + if (Navigation.getReportRHPActiveRoute()) { + return; + } + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + }); + return; + } + const index = sortedVisibleReportActions.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; + }, [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(); + }); + }, [lastIOUActionWithError?.reportActionID, reportScrollManager]); + + // 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, undefined, undefined, backTo)); + } + 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); + } + }; + + const retryLoadNewerChatsError = () => { + loadNewerChats(true); + }; + + return { + trackVerticalScrolling, + onViewableItemsChanged, + isFloatingMessageCounterVisible, + scrollToBottomAndMarkReportAsRead, + onStartReached, + onEndReached, + onLayoutInner, + retryLoadNewerChatsError, + actionIdToHighlight, + }; +} + +export default useReportActionsScroll; +export type {UseReportActionsScrollParams, UseReportActionsScrollResult}; diff --git a/src/hooks/useReportActionsVisibility.ts b/src/hooks/useReportActionsVisibility.ts new file mode 100644 index 000000000000..6814391f6f23 --- /dev/null +++ b/src/hooks/useReportActionsVisibility.ts @@ -0,0 +1,104 @@ +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {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 type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; +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[]; + hasPreviousMessages: boolean; + showFullHistory: boolean; + handleShowPreviousMessages: () => void; +}; + +type UseReportActionsVisibilityParams = { + reportID: string | undefined; + reportActions: ReportAction[]; + canPerformWriteAction: boolean; + hasOlderActions: boolean; + loadOlderChats: (force?: boolean) => void; +}; + +const buildReportVisibleActionsSelector = (reportID: string | undefined) => (data: VisibleReportActionsDerivedValue | undefined) => (reportID ? data?.[reportID] : undefined); + +function useReportActionsVisibility({reportID, reportActions, canPerformWriteAction, hasOlderActions, loadOlderChats}: UseReportActionsVisibilityParams): UseReportActionsVisibilityResult { + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [reportVisibleActions] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, { + selector: buildReportVisibleActionsSelector(reportID), + }); + const visibleReportActionsData: VisibleReportActionsDerivedValue | undefined = reportID && reportVisibleActions ? {[reportID]: reportVisibleActions} : undefined; + + const {transactions, isLoaded: areTransactionsLoaded} = useTransactionsAndViolationsForReport(reportID); + // When transactions haven't loaded yet, pass undefined to skip IOU filtering entirely + // (undefined = "don't filter" in isIOUActionMatchingTransactionList). + // Once loaded, filter normally — even if transactions is empty (genuinely no transactions). + const reportTransactionIDs = areTransactionsLoaded ? getAllNonDeletedTransactions(transactions, reportActions).map((t) => t.transactionID) : undefined; + + 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, + hasPreviousMessages: concierge.hasPreviousMessages, + showFullHistory: concierge.showFullHistory, + handleShowPreviousMessages: concierge.handleShowPreviousMessages, + }; +} + +export default useReportActionsVisibility; diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 180196dd0b3e..7b8c04185cf6 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -5,16 +5,17 @@ import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import type ReportScrollManagerData from './types'; function useReportScrollManager(): ReportScrollManagerData { - const {flatListRef, scrollPositionRef} = useContext(ActionListContext); + const {getListRef, scrollPositionRef} = useContext(ActionListContext); /** * Scroll to the provided index. */ const scrollToIndex = (index: number) => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } - flatListRef.current.scrollToIndex({index}); + listRef.current.scrollToIndex({index}); }; /** @@ -22,40 +23,51 @@ function useReportScrollManager(): ReportScrollManagerData { * When FlatList is inverted it's "bottom" is really it's top */ const scrollToBottom = () => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } scrollPositionRef.current = {offset: 0}; - flatListRef.current?.scrollToOffset({animated: false, offset: 0}); + listRef.current?.scrollToOffset({animated: false, offset: 0}); }; /** * Scroll to the end of the FlatList. */ const scrollToEnd = () => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } - const scrollViewRef = flatListRef.current.getNativeScrollRef(); + const scrollViewRef = listRef.current.getNativeScrollRef?.() as ScrollView | undefined; // Try to scroll on underlying scrollView if available, fallback to usual listRef - if (scrollViewRef && 'scrollToEnd' in scrollViewRef) { - (scrollViewRef as ScrollView).scrollToEnd({animated: false}); + if (scrollViewRef && typeof scrollViewRef.scrollToEnd === 'function') { + scrollViewRef.scrollToEnd({animated: false}); return; } - flatListRef.current.scrollToEnd({animated: false}); + listRef.current.scrollToEnd({animated: false}); }; const scrollToOffset = (offset: number) => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } - flatListRef.current.scrollToOffset({offset, animated: false}); + listRef.current.scrollToOffset({offset, animated: false}); }; - return {ref: flatListRef, scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; + const scrollToIndexInstance = ({index, animated}: {index: number; animated: boolean}) => { + const listRef = getListRef(); + if (!listRef?.current) { + return; + } + listRef.current.scrollToIndex({index, animated}); + }; + + return {scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset, scrollToIndexInstance}; } export default useReportScrollManager; diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index 6b888584887c..7a1eb42619bb 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -3,17 +3,18 @@ import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import type ReportScrollManagerData from './types'; function useReportScrollManager(): ReportScrollManagerData { - const {flatListRef} = useContext(ActionListContext); + const {getListRef} = useContext(ActionListContext); /** * Scroll to the provided index. On non-native implementations we do not want to scroll when we are scrolling because */ const scrollToIndex = (index: number, isEditing?: boolean) => { - if (!flatListRef?.current || isEditing) { + const listRef = getListRef(); + if (!listRef?.current || isEditing) { return; } - flatListRef.current.scrollToIndex({index, animated: true}); + listRef.current.scrollToIndex({index, animated: true}); }; /** @@ -21,33 +22,44 @@ function useReportScrollManager(): ReportScrollManagerData { * When FlatList is inverted it's "bottom" is really it's top */ const scrollToBottom = () => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } - flatListRef.current.scrollToOffset({animated: false, offset: 0}); + listRef.current.scrollToOffset({animated: false, offset: 0}); }; /** * Scroll to the end of the FlatList. */ const scrollToEnd = () => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } - flatListRef.current.scrollToEnd({animated: false}); + listRef.current.scrollToEnd({animated: false}); }; const scrollToOffset = (offset: number) => { - if (!flatListRef?.current) { + const listRef = getListRef(); + if (!listRef?.current) { return; } - flatListRef.current.scrollToOffset({animated: true, offset}); + listRef.current.scrollToOffset({animated: true, offset}); }; - return {ref: flatListRef, scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; + const scrollToIndexInstance = ({index, animated}: {index: number; animated: boolean}) => { + const listRef = getListRef(); + if (!listRef?.current) { + return; + } + listRef.current.scrollToIndex({index, animated}); + }; + + return {scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset, scrollToIndexInstance}; } export default useReportScrollManager; diff --git a/src/hooks/useReportScrollManager/types.ts b/src/hooks/useReportScrollManager/types.ts index 44ccda75e55f..816ef2bb1bb9 100644 --- a/src/hooks/useReportScrollManager/types.ts +++ b/src/hooks/useReportScrollManager/types.ts @@ -1,11 +1,10 @@ -import type {FlatListRefType} from '@pages/inbox/ReportScreenContext'; - type ReportScrollManagerData = { - ref: FlatListRefType; scrollToIndex: (index: number, isEditing?: boolean) => void; scrollToBottom: () => void; scrollToEnd: () => void; scrollToOffset: (offset: number) => void; + /** Imperative scroll-to-index used by ReportActionItemMessageEdit's Safari keyboard hack. */ + scrollToIndexInstance: (params: {index: number; animated: boolean}) => void; }; export default ReportScrollManagerData; diff --git a/src/hooks/useTransactionThread.ts b/src/hooks/useTransactionThread.ts new file mode 100644 index 000000000000..16a9074155c9 --- /dev/null +++ b/src/hooks/useTransactionThread.ts @@ -0,0 +1,79 @@ +import {useCallback} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getOneTransactionThreadReportID, getSortedReportActionsForDisplay} from '@libs/ReportActionsUtils'; +import {canUserPerformWriteAction} 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 = { + 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 [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 { + transactionThreadReportID, + transactionThreadReportActions, + transactionThreadReport, + parentReportActionForTransactionThread, + }; +} + +export default useTransactionThread; diff --git a/src/hooks/useTransactionsAndViolationsForReport.ts b/src/hooks/useTransactionsAndViolationsForReport.ts index ae2a4397cb05..7e5e9fdbb8cd 100644 --- a/src/hooks/useTransactionsAndViolationsForReport.ts +++ b/src/hooks/useTransactionsAndViolationsForReport.ts @@ -23,7 +23,7 @@ function useTransactionsAndViolationsForReport(reportID?: string) { filteredViolations[transactionViolationKey] = getTransactionViolations(transaction, violations, currentUserDetails.email ?? '', currentUserDetails.accountID, report, policy) ?? []; } - return {transactions, violations: filteredViolations}; + return {transactions, violations: filteredViolations, isLoaded: allReportsTransactionsAndViolations !== undefined}; } export default useTransactionsAndViolationsForReport; diff --git a/src/hooks/useUnreadMarker.ts b/src/hooks/useUnreadMarker.ts new file mode 100644 index 000000000000..99e32307cc97 --- /dev/null +++ b/src/hooks/useUnreadMarker.ts @@ -0,0 +1,128 @@ +import type {RefObject} from 'react'; +import {useEffect, useState} from 'react'; +import {DeviceEventEmitter} from 'react-native'; +import {wasMessageReceivedWhileOffline} from '@libs/ReportActionsUtils'; +import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useIsAnonymousUser from './useIsAnonymousUser'; +import useLocalize from './useLocalize'; +import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; +import useOnyx from './useOnyx'; +import usePrevious from './usePrevious'; + +type UseUnreadMarkerParams = { + /** 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; +}; + +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 ?? ''; + + // `key={reportID}` at the list level remounts this hook on report change, + // so the initializer is always the fresh `lastReadTime` for the active report. + const [unreadMarkerTime, setUnreadMarkerTime] = useState(lastReadTime); + + /** + * 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); + + /** + * 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, + isOffline, + isReversed: false, + isAnonymousUser, + }); + + // 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. in an effect) is the + // React-recommended pattern for this — avoids cascading commits. + // https://react.dev/reference/react/useState#storing-information-from-previous-renders + 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/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 312e868ffa3d..fce5cc930044 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1593,7 +1593,7 @@ function withDEWRoutedActionsObject(reportActions: OnyxEntry): On } /** - * This method returns the report actions that are ready for display in the ReportActionsView. + * This method returns the report actions that are ready for display in ReportActionsList. * The report actions need to be sorted by created timestamp first, and reportActionID second * to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp). * This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY. diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index cc96dc1cecba..f8449be16ee0 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -592,7 +592,7 @@ function unsubscribeFromLeavingRoomReportChannel(reportID: string | undefined) { let newActionSubscribers: ActionSubscriber[] = []; /** - * Enables the Report actions file to let the ReportActionsView know that a new comment has arrived in realtime for the current report + * Enables the Report actions file to let ReportActionsList know that a new comment has arrived in realtime for the current report * Add subscriber for report id * @returns Remove subscriber for report id */ @@ -603,7 +603,7 @@ function subscribeToNewActionEvent(reportID: string, callback: SubscriberCallbac }; } -/** Notify the ReportActionsView that a new comment has arrived */ +/** Notify ReportActionsList that a new comment has arrived */ function notifyNewAction(reportID: string | string[] | undefined, reportAction: ReportAction | undefined, isFromCurrentUser: boolean) { if (!reportID) { return; diff --git a/src/pages/inbox/ReportActionsList.tsx b/src/pages/inbox/ReportActions.tsx similarity index 82% rename from src/pages/inbox/ReportActionsList.tsx rename to src/pages/inbox/ReportActions.tsx index bf4cf3e1b71f..3c548c82225c 100644 --- a/src/pages/inbox/ReportActionsList.tsx +++ b/src/pages/inbox/ReportActions.tsx @@ -10,7 +10,8 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getAllNonDeletedTransactions, shouldDisplayReportTableView, shouldWaitForTransactions as shouldWaitForTransactionsUtil} from '@libs/MoneyRequestReportUtils'; import {isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import ReportActionsView from './report/ReportActionsView'; +import ReportActionsList from './report/ReportActionsList'; +import UserTypingEventListener from './report/UserTypingEventListener'; const defaultReportMetadata = { hasOnceLoadedReportActions: false, @@ -23,11 +24,11 @@ const defaultReportMetadata = { }; /** - * Lightweight orchestrator that decides between skeleton, ReportActionsView, + * Lightweight orchestrator that decides between skeleton, ReportActionsList, * or MoneyRequestReportActionsList. Only subscribes to what the branching * conditions need — heavy data derivation is pushed into each child. */ -function ReportActionsList() { +function ReportActions() { const route = useRoute(); const routeParams = route.params as {reportID?: string} | undefined; const reportIDFromRoute = getNonEmptyStringOnyxID(routeParams?.reportID); @@ -45,15 +46,29 @@ function ReportActionsList() { const shouldWaitForTransactions = shouldWaitForTransactionsUtil(report, reportTransactions, reportMetadata, isOffline); const shouldDisplayMoneyRequestActionsList = isMoneyRequestOrInvoiceReport && shouldDisplayReportTableView(report, reportTransactions); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + if (!report || shouldWaitForTransactions) { return ; } + if (isLoadingApp && !isOffline) { + return ; + } + if (shouldDisplayMoneyRequestActionsList) { return ; } - return ; + return ( + <> + + + + ); } -export default ReportActionsList; +export default ReportActions; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index 44fa007a214b..df9298447398 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -21,7 +21,7 @@ import DeleteTransactionNavigateBackHandler from './DeleteTransactionNavigateBac import LinkedActionNotFoundGuard from './LinkedActionNotFoundGuard'; import ReactionListWrapper from './ReactionListWrapper'; import ReportFooter from './report/ReportFooter'; -import ReportActionsList from './ReportActionsList'; +import ReportActions from './ReportActions'; import ReportDragAndDropProvider from './ReportDragAndDropProvider'; import ReportFetchHandler from './ReportFetchHandler'; import ReportHeader from './ReportHeader'; @@ -82,7 +82,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden]} testID="report-actions-view-wrapper" > - + diff --git a/src/pages/inbox/ReportScreenContext.ts b/src/pages/inbox/ReportScreenContext.ts index 50b1e3e707f9..a629b5018d21 100644 --- a/src/pages/inbox/ReportScreenContext.ts +++ b/src/pages/inbox/ReportScreenContext.ts @@ -1,7 +1,7 @@ import type {RefObject, SyntheticEvent} from 'react'; import {createContext} from 'react'; // eslint-disable-next-line no-restricted-imports -import type {FlatList, GestureResponderEvent, Text, View} from 'react-native'; +import type {GestureResponderEvent, Text, View} from 'react-native'; type ReactionListAnchor = View | Text | HTMLDivElement | null; @@ -13,18 +13,40 @@ type ReactionListRef = { isActiveReportAction: (actionID: number | string) => boolean; }; -type FlatListRefType = RefObject | null> | null; +// Duck-typed imperative interface shared by the two list instances we may host +// (InvertedFlashList's FlashListRef and FlatListWithScrollKey's RN FlatList). Only the methods +// actually invoked by scroll handlers are listed; native-only `getNativeScrollRef` is guarded +// at the call site. +type ListInstanceType = { + scrollToIndex: (params: {index: number; animated?: boolean}) => void; + scrollToOffset: (params: {offset: number; animated?: boolean}) => void; + scrollToEnd: (params?: {animated?: boolean}) => void; + getNativeScrollRef?: () => unknown; +}; +// Ref object types are invariant in React — we widen to `unknown` at the context boundary and +// keep specific leaf types (FlashListRef, FlatList) at each list site. +type FlatListRefType = RefObject | null; type ScrollPosition = {offset?: number}; type ActionListContextType = { - flatListRef: FlatListRefType; + /** Child list components call this on mount to publish their list instance ref. Pass `null` to clear on unmount. */ + registerListRef: (ref: RefObject | null) => void; + + /** Handlers (scrollTo*, Safari keyboard hack) call this to read the currently registered list ref. Never use during render. */ + getListRef: () => RefObject | null; + scrollPositionRef: RefObject; scrollOffsetRef: RefObject; }; type ReactionListContextType = RefObject | null; -const ActionListContext = createContext({flatListRef: null, scrollPositionRef: {current: {}}, scrollOffsetRef: {current: 0}}); +const ActionListContext = createContext({ + registerListRef: () => {}, + getListRef: () => null, + scrollPositionRef: {current: {}}, + scrollOffsetRef: {current: 0}, +}); const ReactionListContext = createContext(null); export {ActionListContext, ReactionListContext}; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 7f3600739379..94b9c1507747 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -207,7 +207,7 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { // the parent report's actions — not combined with transaction thread actions. The table view // doesn't display transaction thread comments inline, so the last editable action should only // come from what's visible in the parent report. ReportScreen (inbox) uses combinedReportActions - // because ReportActionsView merges thread comments into the visible list, and up-arrow-to-edit + // because ReportActionsList merges thread comments into the visible list, and up-arrow-to-edit // should be able to reach those comments. const actionsForLastEditable = isOnSearchMoneyRequestReport ? filteredReportActions : combinedReportActions; const lastReportAction = useMemo( diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 97e5b5fcff09..595adeb0f2df 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -551,8 +551,8 @@ function ReportActionItemMessageEdit({ endScrollBlock(); }); }); - if (isMobileChrome() && reportScrollManager.ref?.current) { - reportScrollManager.ref.current.scrollToIndex({index, animated: false}); + if (isMobileChrome()) { + reportScrollManager.scrollToIndexInstance({index, animated: false}); } setShouldShowComposeInputKeyboardAware(false); // The last composer that had focus should re-gain focus diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 5d5285ece871..bde24178a399 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,860 +1,276 @@ -import {useIsFocused, useRoute} from '@react-navigation/native'; import {isUserValidatedSelector} from '@selectors/Account'; import {tierNameSelector} from '@selectors/UserWallet'; -import type {ListRenderItemInfo} from '@shopify/flash-list'; -import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; +import React, {useContext, useEffect, useRef} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; +import {View} from 'react-native'; import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; -import Button from '@components/Button'; import InvertedFlashList from '@components/FlashList/InvertedFlashList'; -import getShowScrollIndicator from '@components/FlashList/InvertedFlashList/getShowScrollIndicator'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; -import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus'; +import useMarkAsRead from '@hooks/useMarkAsRead'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePrevious from '@hooks/usePrevious'; +import useParentReportAction from '@hooks/useParentReportAction'; +import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; +import useReportActionsPagination from '@hooks/useReportActionsPagination'; +import useReportActionsScroll from '@hooks/useReportActionsScroll'; +import useReportActionsVisibility from '@hooks/useReportActionsVisibility'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; -import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import {isSafari} from '@libs/Browser'; +import useUnreadMarker from '@hooks/useUnreadMarker'; +import {updateLoadingInitialReportAction} from '@libs/actions/Report'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; -import DateUtils from '@libs/DateUtils'; import FS from '@libs/Fullstory'; -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 type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import { getFirstVisibleReportActionID, isConsecutiveActionMadeByPreviousActor, - isCurrentActionUnread, isDeletedParentAction, - isReportPreviewAction, isReversedTransaction, isSentMoneyReportAction, isTransactionThread, - wasMessageReceivedWhileOffline, } from '@libs/ReportActionsUtils'; import { canShowReportRecipientLocalTime, canUserPerformWriteAction, chatIncludesChronosWithID, getOriginalReportID, - getReportLastVisibleActionCreated, - isArchivedNonExpenseReport, isCanceledTaskReport, isExpenseReport, isInvoiceReport, isIOUReport, isMoneyRequestReport, isTaskReport, - isUnread, } from '@libs/ReportUtils'; -import Visibility from '@libs/Visibility'; -import type {ReportsSplitNavigatorParamList} from '@navigation/types'; -import ConciergeThinkingMessage from '@pages/home/report/ConciergeThinkingMessage'; +import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; -import variables from '@styles/variables'; -import {openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import FloatingMessageCounter from './FloatingMessageCounter'; -import getInitialNumToRender from './getInitialNumReportActionsToRender'; -import getReportActionsListInitialNumToRender from './getReportActionsListInitialNumToRender'; -import ListBoundaryLoader from './ListBoundaryLoader'; +import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; -import {getUnreadMarkerReportAction} from './shouldDisplayNewMarkerOnReportAction'; -import StaticReportActionsPreview from './StaticReportActionsPreview'; -import useReportUnreadMessageScrollTracking from './useReportUnreadMessageScrollTracking'; +import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; type ReportActionsListProps = { - /** The report currently being looked at */ - report: OnyxTypes.Report; + /** The ID of the report to display actions for */ + reportID: string; - /** The transaction thread report associated with the current report, if any */ - transactionThreadReport: OnyxEntry; - - /** The report's parentReportAction */ - parentReportAction: OnyxEntry; - - /** The transaction thread report's parentReportAction */ - parentReportActionForTransactionThread: OnyxEntry; - - /** Sorted actions prepared for display */ - sortedReportActions: OnyxTypes.ReportAction[]; - - /** Sorted actions that should be visible to the user */ - sortedVisibleReportActions: OnyxTypes.ReportAction[]; - - /** Callback executed on list layout */ - onLayout: (event: LayoutChangeEvent) => void; - - /** Callback executed on scroll */ - onScroll?: (event: NativeSyntheticEvent) => void; - - /** Function to load more chats */ - loadOlderChats: (force?: boolean) => void; - - /** Function to load newer chats */ - loadNewerChats: (force?: boolean) => void; - - /** Whether the composer is in full size */ - isComposerFullSize?: boolean; - - /** ID of the list */ - listID: number; - - /** Whether the optimistic CREATED report action was added */ - hasCreatedActionAdded?: boolean; - - /** Whether this is a Concierge chat in the side panel */ - isConciergeSidePanel?: boolean; - - /** Whether the chat history is hidden (concierge side panel fresh state) */ - showHiddenHistory?: boolean; - - /** Whether there are previous messages that can be revealed */ - hasPreviousMessages?: boolean; - - /** Callback to show previous messages */ - onShowPreviousMessages?: () => void; + /** Callback executed on layout */ + onLayout?: (event: LayoutChangeEvent) => void; }; -// 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> = {}; - -// 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 - * random enough to avoid collisions - */ function keyExtractor(item: OnyxTypes.ReportAction): string { return item.reportActionID; } -function ReportActionsList({ - report, - transactionThreadReport, - parentReportAction, - sortedReportActions, - sortedVisibleReportActions, - onScroll, - loadNewerChats, - loadOlderChats, - onLayout, - isComposerFullSize, - listID, - parentReportActionForTransactionThread, - hasCreatedActionAdded, - isConciergeSidePanel, - showHiddenHistory, - hasPreviousMessages, - onShowPreviousMessages, -}: ReportActionsListProps) { - const prevHasCreatedActionAdded = usePrevious(hasCreatedActionAdded); +function ReportActionsList({reportID, onLayout}: ReportActionsListProps) { + // ─── Side effects ─── + useCopySelectionHelper(); + usePendingConciergeResponse(reportID); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const personalDetailsList = usePersonalDetails(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow']); - const {windowHeight} = useWindowDimensions(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {getLocalDateFromDatetime} = useLocalize(); - const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); - const route = useRoute>(); - const reportScrollManager = useReportScrollManager(); - const {scrollOffsetRef} = useContext(ActionListContext); - const userActiveSince = useRef(DateUtils.getDBTime()); - const lastMessageTime = useRef(null); - const [isVisible, setIsVisible] = useState(Visibility.isVisible); - const isFocused = useIsFocused(); - - const isReportArchived = useReportIsArchived(report?.reportID); - const [userWalletTierName] = useOnyx(ONYXKEYS.USER_WALLET, { - selector: tierNameSelector, + // ─── Core data ─── + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`); + const parentReportAction = useParentReportAction(report); + const isReportArchived = useReportIsArchived(reportID); + const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); + + const {isOffline} = useNetwork(); + + // ─── Pipeline: pagination → load → visibility ─── + const pagination = useReportActionsPagination(reportID); + + const {loadOlderChats, loadNewerChats} = useLoadReportActions({ + reportID, + reportActions: pagination.reportActions, + allReportActionIDs: pagination.allReportActionIDs, + transactionThreadReportID: pagination.transactionThreadReport?.reportID, + hasOlderActions: pagination.hasOlderActions, + hasNewerActions: pagination.hasNewerActions, }); - const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, { - selector: isUserValidatedSelector, - }); - const [reportActionsFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`); - const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); - const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT); - const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); - const [actionIdToHighlight, setActionIdToHighlight] = useState(''); - const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`); - - const backTo = route?.params?.backTo as string; - const linkedReportActionID = route?.params?.reportActionID; - - const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); - const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); - const shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); - const topReportAction = sortedVisibleReportActions.at(-1); - const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !linkedReportActionID); - const scrollEndTimerRef = useRef | undefined>(undefined); - const isAnonymousUser = useIsAnonymousUser(); - useEffect(() => { - const unsubscribe = Visibility.onVisibilityChange(() => { - setIsVisible(Visibility.isVisible()); - }); - - return unsubscribe; - }, []); - - const readActionSkipped = useRef(false); - const hasHeaderRendered = useRef(false); - - const lastAction = sortedVisibleReportActions.at(0); - const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( - () => - sortedVisibleReportActions.reduce((actions, action) => { - Object.assign(actions, {[action.reportActionID]: action}); - return actions; - }, {}), - [sortedVisibleReportActions], - ); - const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); - - const reportLastReadTime = report.lastReadTime ?? ''; - - /** - * 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 - */ - const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime); - useEffect(() => { - setUnreadMarkerTime(reportLastReadTime); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [report.reportID]); - - const prevUnreadMarkerReportActionID = useRef(null); - - /** - * 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]); - - /** - * The reportActionID the unread marker should display above - */ - const [unreadMarkerReportActionID, unreadMarkerReportActionIndex] = getUnreadMarkerReportAction({ + const { visibleReportActions: sortedVisibleReportActions, - earliestReceivedOfflineMessageIndex, - currentUserAccountID, - prevSortedVisibleReportActionsObjects, - unreadMarkerTime, - scrollingVerticalOffset: scrollOffsetRef.current, - prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, - isOffline, - isReversed: false, - isAnonymousUser, + hasPreviousMessages, + showFullHistory, + handleShowPreviousMessages, + } = useReportActionsVisibility({ + reportID, + reportActions: pagination.reportActions, + canPerformWriteAction: !!canPerformWriteAction, + hasOlderActions: pagination.hasOlderActions, + loadOlderChats, }); - prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; - - /** - * 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); + // ─── Unread marker + mark-as-read ─── + const {scrollOffsetRef, registerListRef} = useContext(ActionListContext); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastAction?.created]); + // Own the list ref locally; publish to context so scroll handlers (useReportScrollManager) + // and ReportActionItemMessageEdit's Safari hack can reach the instance via `getListRef()`. + // Keeping the ref local lets React Compiler compile this component — a ref in context would + // trigger `react-hooks/refs` when passed to JSX `ref={}`. + const listRef = useRef | null>(null); + useEffect(() => { + registerListRef(listRef); + return () => registerListRef(null); + }, [registerListRef]); + + const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useUnreadMarker({ + reportID, + sortedVisibleReportActions, + sortedReportActions: pagination.reportActions, + scrollingVerticalOffset: scrollOffsetRef, + }); - const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); - const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated || isReportPreviewAction(lastAction); - const hasNewestReportActionRef = useRef(hasNewestReportAction); - hasNewestReportActionRef.current = hasNewestReportAction; - const sortedVisibleReportActionsRef = useRef(sortedVisibleReportActions); + const {readActionSkippedRef} = useMarkAsRead({ + reportID, + sortedVisibleReportActions, + transactionThreadReport: pagination.transactionThreadReport, + scrollingVerticalOffset: scrollOffsetRef, + }); - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ - reportID: report.reportID, - currentVerticalScrollingOffsetRef: scrollOffsetRef, - readActionSkippedRef: readActionSkipped, + // ─── Scroll state ─── + const isTransactionThreadReport = isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction); + const isMoneyRequestOrInvoiceReport = isMoneyRequestReport(report) || isInvoiceReport(report); + const shouldFocusToTopOnMount = isTransactionThreadReport || isMoneyRequestOrInvoiceReport; + + const linkedReportActionID = pagination.reportActionID; + + const { + trackVerticalScrolling, + onViewableItemsChanged, + isFloatingMessageCounterVisible, + scrollToBottomAndMarkReportAsRead, + onStartReached, + onEndReached, + onLayoutInner, + retryLoadNewerChatsError, + actionIdToHighlight, + } = useReportActionsScroll({ + reportID, + report, + sortedVisibleReportActions, + readActionSkippedRef, unreadMarkerReportActionIndex, - isInverted: true, - onTrackScrolling: (event: NativeSyntheticEvent) => { - scrollOffsetRef.current = event.nativeEvent.contentOffset.y; - onScroll?.(event); - // We use a timeout to wait for the scroll to finish before resetting the flag. - // onMomentumScrollEnd would be ideal but it doesn't work on web. - if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline) && !scrollEndTimerRef.current) { - scrollEndTimerRef.current = setTimeout(() => { - setShouldScrollToEndAfterLayout(false); - scrollEndTimerRef.current = undefined; - }, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME); - } - }, + loadOlderChats, + loadNewerChats, + linkedReportActionID, hasOnceLoadedReportActions: !!reportMetadata?.hasOnceLoadedReportActions, + onLayout, }); - useEffect(() => () => clearTimeout(scrollEndTimerRef.current), []); - - useScrollToEndOnNewMessageReceived({ - sizeChangeType: 'changed', - scrollOffsetRef, - lastActionID: lastAction?.reportActionID, - visibleActionsLength: sortedVisibleReportActions.length, - hasNewestReportAction, - setIsFloatingMessageCounterVisible, - scrollToEnd: reportScrollManager.scrollToBottom, - resetKey: linkedReportActionID, - }); - + // ─── Linked message offline effect ─── useEffect(() => { - const shouldTriggerScroll = shouldFocusToTopOnMount && prevHasCreatedActionAdded && !hasCreatedActionAdded; - if (!shouldTriggerScroll) { + if (!linkedReportActionID || !isOffline) { return; } - requestAnimationFrame(() => reportScrollManager.scrollToEnd()); - }, [hasCreatedActionAdded, prevHasCreatedActionAdded, shouldFocusToTopOnMount, shouldScrollToEndAfterLayout, reportScrollManager]); + updateLoadingInitialReportAction(report?.reportID ?? reportID); + }, [isOffline, report?.reportID, reportID, linkedReportActionID]); - useEffect(() => { - userActiveSince.current = DateUtils.getDBTime(); - prevReportID = report.reportID; - }, [report.reportID]); - - const handleReportChangeMarkAsRead = useCallback(() => { - if (report.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) && scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { - readNewestAction(report.reportID, !!reportMetadata?.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, reportMetadata?.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, !!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 - }, [isFocused, isVisible, reportMetadata?.hasOnceLoadedReportActions]); - - const prevHandleReportChangeMarkAsRead = useRef<() => void>(null); - const prevHandleAppVisibilityMarkAsRead = useRef<() => void>(null); + // ─── START renderItem deps (can't be inside renderItem yet before list-item-level useOnyx is allowed — hooks must be at top level) ─── + const personalDetailsList = usePersonalDetails(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [userWalletTierName] = useOnyx(ONYXKEYS.USER_WALLET, {selector: tierNameSelector}); + const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector}); + const [reportActionsFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); + const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); + const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT); + const isTryNewDotNVPDismissed = !!tryNewDot?.classicRedirect?.dismissed; + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); - useEffect(() => { - let isMarkedAsRead = false; - if (handleReportChangeMarkAsRead !== prevHandleReportChangeMarkAsRead.current) { - isMarkedAsRead = !!handleReportChangeMarkAsRead(); - } + const firstVisibleReportActionID = getFirstVisibleReportActionID(pagination.reportActions, isOffline); + const shouldHideThreadDividerLine = firstVisibleReportActionID === unreadMarkerReportActionID; - if (!isMarkedAsRead && handleAppVisibilityMarkAsRead !== prevHandleAppVisibilityMarkAsRead.current) { - handleAppVisibilityMarkAsRead(); - } + let shouldUseThreadDividerLine = false; + const topReport = sortedVisibleReportActions.length > 0 ? sortedVisibleReportActions.at(sortedVisibleReportActions.length - 1) : null; - prevHandleReportChangeMarkAsRead.current = handleReportChangeMarkAsRead; - prevHandleAppVisibilityMarkAsRead.current = handleAppVisibilityMarkAsRead; - }, [handleReportChangeMarkAsRead, handleAppVisibilityMarkAsRead]); + if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { + shouldUseThreadDividerLine = false; + } else if (isTransactionThread(parentReportAction)) { + shouldUseThreadDividerLine = !isDeletedParentAction(parentReportAction) && !isReversedTransaction(parentReportAction); + } else if (isTaskReport(report)) { + shouldUseThreadDividerLine = !isCanceledTaskReport(report, parentReportAction); + } else { + shouldUseThreadDividerLine = isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); + } - useEffect(() => { - if (linkedReportActionID) { - return; - } + const isWriteActionDisabled = !canUserPerformWriteAction(report, isReportArchived); + const shouldShowReportRecipientLocalTime = canShowReportRecipientLocalTime(personalDetailsList, report, currentUserAccountID); + // ─── END renderItem deps ─── - // 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 - }, []); - - // Fixes Safari-specific issue where the whisper option is not highlighted correctly on hover after adding new transaction. - // https://github.com/Expensify/App/issues/54520 - useEffect(() => { - if (!isSafari()) { - return; - } - const prevSorted = lastAction?.reportActionID ? prevSortedVisibleReportActionsObjects[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, prevSortedVisibleReportActionsObjects, reportScrollManager]); + const isLoadingInitialReportActions = reportMetadata?.isLoadingInitialReportActions; + const isMissingReportActions = sortedVisibleReportActions.length === 0; - useEffect(() => { - sortedVisibleReportActionsRef.current = sortedVisibleReportActions; - }, [sortedVisibleReportActions]); - - const scrollToBottomForCurrentUserAction = useCallback( - (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(report.reportID)); - }); - return; - } - const index = sortedVisibleReportActionsRef.current.findIndex((item) => keyExtractor(item) === 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); - }); - }, - [report.reportID, reportScrollManager, setIsFloatingMessageCounterVisible], - ); + const shouldShowSkeleton = isLoadingInitialReportActions && isMissingReportActions && !isOffline; - // Clear the highlighted report action after scrolling and highlighting useEffect(() => { - if (actionIdToHighlight === '') { + if (!shouldShowSkeleton || !report) { return; } - // Time highlight is the same as SearchPage - const timer = setTimeout(() => { - setActionIdToHighlight(''); - }, durationHighlightItem); - return () => clearTimeout(timer); - }, [actionIdToHighlight]); - - 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[report.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(report.reportID, scrollToBottomForCurrentUserAction); - - const cleanup = () => { - if (!unsubscribe) { - return; - } - unsubscribe(); - }; - - newActionUnsubscribeMap[report.reportID] = cleanup; - - return cleanup; + markOpenReportEnd(report, {warm: false}); + }, [report, shouldShowSkeleton]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [report.reportID]); + if (shouldShowSkeleton) { + return ; + } const reportActionsListFSClass = FS.getChatFSClass(report); - const lastIOUActionWithError = sortedVisibleReportActions.find((action) => action.errors); - const prevLastIOUActionWithError = usePrevious(lastIOUActionWithError); - - useEffect(() => { - if (lastIOUActionWithError?.reportActionID === prevLastIOUActionWithError?.reportActionID) { - return; - } - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - reportScrollManager.scrollToBottom(); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastAction]); - - const scrollToBottomAndMarkReportAsRead = useCallback(() => { - setIsFloatingMessageCounterVisible(false); - - if (!hasNewestReportAction) { - if (!Navigation.getReportRHPActiveRoute()) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID, undefined, undefined, backTo)); - } - openReport({reportID: report.reportID, introSelected, betas}); - reportScrollManager.scrollToBottom(); - return; - } - reportScrollManager.scrollToBottom(); - readActionSkipped.current = false; - readNewestAction(report.reportID, !!reportMetadata?.hasOnceLoadedReportActions); - }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, report.reportID, backTo, introSelected, reportMetadata?.hasOnceLoadedReportActions, betas]); - - /** - * Calculates the ideal number of report actions to render in the first render, based on the screen height and on - * the height of the smallest report action possible. - */ - const initialNumToRender = useMemo((): number | undefined => { - const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom + variables.fontSizeNormalHeight; - const availableHeight = windowHeight - (CONST.CHAT_FOOTER_MIN_HEIGHT + variables.contentHeaderHeight); - const numToRender = Math.ceil(availableHeight / minimumReportActionHeight); - return getReportActionsListInitialNumToRender({ - numToRender, - linkedReportActionID, - shouldScrollToEndAfterLayout, - hasCreatedActionAdded, - sortedVisibleReportActionsLength: sortedVisibleReportActions.length, - isOffline, - getInitialNumToRender, - }); - }, [ - styles.chatItem.paddingBottom, - styles.chatItem.paddingTop, - windowHeight, - linkedReportActionID, - shouldScrollToEndAfterLayout, - hasCreatedActionAdded, - sortedVisibleReportActions.length, - isOffline, - ]); - - /** - * Thread's divider line should hide when the first chat in the thread is marked as unread. - * This is so that it will not be conflicting with header's separator line. - */ - const shouldHideThreadDividerLine = useMemo( - (): boolean => getFirstVisibleReportActionID(sortedReportActions, isOffline) === unreadMarkerReportActionID, - [sortedReportActions, isOffline, unreadMarkerReportActionID], - ); - const firstVisibleReportActionID = useMemo(() => getFirstVisibleReportActionID(sortedReportActions, isOffline), [sortedReportActions, isOffline]); + const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - const shouldUseThreadDividerLine = useMemo(() => { - const topReport = sortedVisibleReportActions.length > 0 ? sortedVisibleReportActions.at(sortedVisibleReportActions.length - 1) : null; + const extraData = shouldUseNarrowLayout ? unreadMarkerReportActionID : undefined; - if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { - return false; - } - - if (isTransactionThread(parentReportAction)) { - return !isDeletedParentAction(parentReportAction) && !isReversedTransaction(parentReportAction); - } - - if (isTaskReport(report)) { - return !isCanceledTaskReport(report, parentReportAction); - } - - return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); - }, [parentReportAction, report, sortedVisibleReportActions]); - - const renderItem = useCallback( - ({item: reportAction, index}: ListRenderItemInfo) => { - const originalReportID = getOriginalReportID(report.reportID, reportAction, reportActionsFromOnyx); - const showPreviousMessagesButton = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && !!isConciergeSidePanel && !!showHiddenHistory && !!hasPreviousMessages; - - return ( - <> - 1} - isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} - shouldUseThreadDividerLine={shouldUseThreadDividerLine} - userWalletTierName={userWalletTierName} - isUserValidated={isUserValidated} - personalDetails={personalDetailsList} - originalReportID={originalReportID} - isReportArchived={isReportArchived} - userBillingFundID={userBillingFundID} - isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} - reportNameValuePairsOrigin={reportNameValuePairs?.origin} - reportNameValuePairsOriginalID={reportNameValuePairs?.originalID} - /> - {showPreviousMessagesButton && ( - - - -