diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx deleted file mode 100644 index a73ce78e0a75..000000000000 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ /dev/null @@ -1,729 +0,0 @@ -import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; -import InvertedFlashList from '@components/FlashList/InvertedFlashList'; -import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; -import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; -import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; - -import useEnvironment from '@hooks/useEnvironment'; -import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; -import useLocalize from '@hooks/useLocalize'; -import useMarkAsRead from '@hooks/useMarkAsRead'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; -import useReportActionsScroll from '@hooks/useReportActionsScroll'; -import useReportScrollManager from '@hooks/useReportScrollManager'; -import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; -import useSidePanelState from '@hooks/useSidePanelState'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useUnreadMarker from '@hooks/useUnreadMarker'; -import useWindowDimensions from '@hooks/useWindowDimensions'; - -import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; -import { - getFirstVisibleReportActionID, - getFeedbackPromptActionIDs, - getReportActionMessage, - isConsecutiveActionMadeByPreviousActor, - isDeletedParentAction, - isNewerReportAction, - isReversedTransaction, - isTransactionThread, - shouldHidePayAction, -} from '@libs/ReportActionsUtils'; -import { - chatIncludesChronosWithID, - isArchivedNonExpenseReport, - isArchivedReport, - isCanceledTaskReport, - isExpenseReport, - isHarvestCreatedExpenseReport, - isInvoiceReport, - isIOUReport, - isResolvedSupportTicket, - isTaskReport, - isSupportTicket, - shouldShowMarkAsDone, -} from '@libs/ReportUtils'; -import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; - -import type {ReportsSplitNavigatorParamList} from '@navigation/types'; - -import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; -import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; -import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; -import {getStableReportSelector} from '@src/selectors/Report'; -import type * as OnyxTypes from '@src/types/onyx'; -import getEmptyArray from '@src/types/utils/getEmptyArray'; - -import type {ListRenderItemInfo} from '@shopify/flash-list'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; - -import {useRoute} from '@react-navigation/native'; -import {getCustomAgentAccountIDs} from '@selectors/AgentZeroChat'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {accountIDSelector} from '@selectors/Session'; -import React, {useEffect, useRef, useState} from 'react'; - -import AskConciergeWelcome from './AskConciergeWelcome'; -import CollapsedSystemMessages from './CollapsedSystemMessages'; -import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; -import FloatingMessageCounter from './FloatingMessageCounter'; -import {ReportActionPositionContextProvider} from './ReportActionIndexContext'; -import {useReportActionsListActions, useReportActionsListState} from './ReportActionsListContext'; -import ReportActionsListHeader from './ReportActionsListHeader'; -import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; -import ReportActionsListPaddingView from './ReportActionsListPaddingView'; -import ReportActionsSkeletonGuard from './ReportActionsSkeletonGuard'; -import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; -import useFollowActionBadgeTarget from './useFollowActionBadgeTarget'; -import useReportActionsPresentation from './useReportActionsPresentation'; -import useScrollToEditingReportAction from './useScrollToEditingReportAction'; - -type ReportActionsListContentProps = { - /** The ID of the report to display actions for */ - reportID: string; - - conciergeChat: OnyxEntry; - onLayout?: (event: LayoutChangeEvent) => void; -}; - -type ReportActionsListProps = ReportActionsListContentProps; - -/** - * 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 { - // A report has exactly one CREATED action. Using a stable key lets FlashList recycle the same cell - // when the optimistic CREATED is swapped for the server one, avoiding a remount-induced scroll jump. - if (item.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { - return CONST.REPORT.ACTIONS.TYPE.CREATED; - } - return item.reportActionID; -} - -/** - * Renders the report-actions list. Reads its data from `ReportActionsListStateContext` / `ReportActionsListActionsContext` and holds the - * UI-close hooks (`useUnreadMarker` / `useMarkAsRead` / `useReportActionsScroll`). `ReportActionsSkeletonGuard` - * mounts it only once content is ready, so those hooks never run while a skeleton shows. - */ -function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportActionsListContentProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {windowHeight} = useWindowDimensions(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {isProduction} = useEnvironment(); - - const { - report, - hasOnceLoadedReportActions, - hasNewerActions, - sortedAllReportActions, - oldestUnreadReportAction, - transactionThreadReport, - parentReportActionForTransactionThread, - treatAsNoPaginationAnchor, - parentReportAction, - sortedReportActions: unfilteredReportActions, - sortedVisibleReportActions: unfilteredVisibleReportActions, - isConciergeHiddenHistory, - showFullHistory, - hasPreviousMessages, - persistedReportActionIDs, - shouldShowConciergeWelcome, - isConciergeHistoryExpanded, - } = useReportActionsListState(); - - // Remove rows hidden by the renderer before calculating summary counts, headers, and scroll positions. - const sortedReportActions = report?.isWaitingOnBankAccount ? unfilteredReportActions.filter((action) => !shouldHidePayAction(action, true)) : unfilteredReportActions; - const sortedVisibleReportActions = report?.isWaitingOnBankAccount - ? unfilteredVisibleReportActions.filter((action) => !shouldHidePayAction(action, true)) - : unfilteredVisibleReportActions; - - const {setTreatAsNoPaginationAnchor, loadOlderChats, loadNewerChats, handleShowPreviousMessages} = useReportActionsListActions(); - - const {isOffline} = useNetwork(); - const route = useRoute>(); - const reportActionIDFromRoute = route?.params?.reportActionID; - const {sessionStartTime} = useSidePanelState(); - const {collapseHistory: collapseConciergeHistory} = useConciergeChatHistoryActions(); - - const didLayout = useRef(false); - - useEffect(() => { - didLayout.current = false; - }, [reportID]); - - useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); - - // Owned here rather than by the callout, which unmounts as the layout and composer change size. - useRetireMerchantRuleSuggestionOnLeave(reportID); - - // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. - const listID = [reportID, reportActionIDFromRoute, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); - - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); - const isReportArchived = !!isArchivedReport(reportNameValuePairs); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - - const reportAttributesSelector = (value: OnyxEntry) => { - const attrs = value?.reports?.[reportID]; - if (!attrs) { - return undefined; - } - return { - actionBadge: attrs.actionBadge, - actionTargetReportActionID: attrs.actionTargetReportActionID, - brickRoadStatus: attrs.brickRoadStatus, - }; - }; - const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - selector: reportAttributesSelector, - }); - const isHarvestCreatedExpenseReportAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); - - const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); - const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); - const reportsParentHierarchy = useReportsParentHierarchy(reportID); - - const linkedReportActionID = reportActionIDFromRoute; - - const {getScrollOffset} = useActionListContext(); - const listRef = useActionListRef(); - - const {draftReportAction, isDraftPendingCompletion} = useConciergeDraft(); - const {clearDraft, revealDraftFromReportAction} = useConciergeDraftActions(); - - const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; - const onShowPreviousMessages = handleShowPreviousMessages; - - const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - const {unreadMarkerReportActionID} = useUnreadMarker({ - reportID, - sortedVisibleReportActions, - sortedReportActions, - oldestUnreadReportActionID: oldestUnreadReportAction?.reportActionID, - isScrolledOverThreshold: hasScrolledOverThreshold, - hasOnceLoadedReportActions: !!hasOnceLoadedReportActions, - newMessageBoundaryTime: isConciergeHiddenHistory ? sessionStartTime : undefined, - }); - - const {markNewestActionAsRead, completeSkippedMarkAsRead} = useMarkAsRead({ - reportID, - report, - transactionThreadReport, - sortedVisibleReportActions, - isScrolledToEnd: !hasScrolledOverThreshold, - hasNewerActions, - }); - - const persistedDraftReportAction = draftReportAction - ? (sortedAllReportActions ?? sortedVisibleReportActions).find( - (action) => action.reportActionID === draftReportAction.reportActionID && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - ) - : undefined; - - const shouldHideSupportTicketSurvey = isSupportTicket(report) && (!isResolvedSupportTicket(report) || !!reportNameValuePairs?.reopenedAsReportID); - const latestResolvedSupportTicketAction = sortedAllReportActions?.find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED); - const latestSupportTicketSurveyAction = latestResolvedSupportTicketAction - ? sortedAllReportActions?.find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY && action.created >= latestResolvedSupportTicketAction.created) - : undefined; - - const visibleReportActions = (() => { - if (!draftReportAction) { - return sortedVisibleReportActions; - } - - if (showHiddenHistory && sessionStartTime && draftReportAction.created < sessionStartTime) { - return sortedVisibleReportActions; - } - - // A completed reveal belongs to the saved action, even when pagination keeps that action off screen. - if (persistedDraftReportAction && !isDraftPendingCompletion) { - return sortedVisibleReportActions; - } - - // Insert the synthetic draft into the already-descending render list without treating it as a persisted report action. - for (const [index, action] of sortedVisibleReportActions.entries()) { - if (action.reportActionID === draftReportAction.reportActionID) { - // Completed local replies can retain their pending add flag after the server merges followups. - if (!isDraftPendingCompletion) { - return sortedVisibleReportActions; - } - - const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; - visibleReportActionsWithDraft[index] = draftReportAction; - return visibleReportActionsWithDraft; - } - if (isNewerReportAction(draftReportAction, action)) { - const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; - visibleReportActionsWithDraft.splice(index, 0, draftReportAction); - return visibleReportActionsWithDraft; - } - } - - const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; - visibleReportActionsWithDraft.push(draftReportAction); - return visibleReportActionsWithDraft; - })(); - - const shouldFilterSupportTicketSurveys = - isSupportTicket(report) && - visibleReportActions.some( - (action) => - action.actionName === CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY && - (shouldHideSupportTicketSurvey || action.reportActionID !== latestSupportTicketSurveyAction?.reportActionID), - ); - const renderedVisibleReportActions = shouldFilterSupportTicketSurveys - ? visibleReportActions.filter( - (action) => - action.actionName !== CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY || - (!shouldHideSupportTicketSurvey && action.reportActionID === latestSupportTicketSurveyAction?.reportActionID), - ) - : visibleReportActions; - - const shouldCollapseSystemMessages = isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report) || isTransactionThread(parentReportAction); - const { - displayReportActions, - runsByAnchorReportActionID, - reportActionIDToDisplayIndex, - expandedSystemMessageReportActionIDs, - unreadMarkerReportActionIndex, - expandSystemMessageRun, - focusRequestedReportActionID, - onExpandedSystemMessageReady, - } = useReportActionsPresentation({ - visibleReportActions: shouldCollapseSystemMessages ? renderedVisibleReportActions : [], - linkedReportActionID, - unreadMarkerReportActionID, - }); - const displayedReportActions = shouldCollapseSystemMessages ? displayReportActions : renderedVisibleReportActions; - const canonicalIndexByReportActionID = new Map(renderedVisibleReportActions.map((action, index) => [action.reportActionID, index])); - const displayedUnreadMarkerIndex = shouldCollapseSystemMessages - ? unreadMarkerReportActionIndex - : renderedVisibleReportActions.findIndex((action) => action.reportActionID === unreadMarkerReportActionID); - - const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; - const draftReportActionID = draftReportAction?.reportActionID; - const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; - const draftAutoScrollKey = isSyntheticDraftVisible ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; - - useEffect(() => { - if (!draftReportAction || isSyntheticDraftVisible) { - return; - } - - clearDraft(); - }, [clearDraft, draftReportAction, isSyntheticDraftVisible]); - - useEffect(() => { - if (!isDraftPendingCompletion || !draftReportAction || !persistedDraftReportAction) { - return; - } - - // The persisted action is the durable completion signal when a terminal Pusher event is missed. - // Reconcile by action ID even when its HTML is byte-identical to the last streamed draft. - revealDraftFromReportAction(persistedDraftReportAction); - }, [draftReportAction, isDraftPendingCompletion, persistedDraftReportAction, revealDraftFromReportAction]); - - const reportScrollManager = useReportScrollManager(); - - // Which rows are on screen, tracked so an edit started on a message the user can already see doesn't move the list. - const viewableRowIndexesRef = useRef>(new Set()); - - const expandedFocusIndex = focusRequestedReportActionID ? (reportActionIDToDisplayIndex.get(focusRequestedReportActionID) ?? -1) : -1; - useEffect(() => { - if (expandedFocusIndex < 0 || viewableRowIndexesRef.current.has(expandedFocusIndex)) { - return; - } - - // An off-window revealed message must mount before it can hand off focus. - const frame = requestAnimationFrame(() => { - listRef.current?.scrollToIndex({index: expandedFocusIndex, animated: false, viewPosition: 0.5}); - }); - return () => cancelAnimationFrame(frame); - }, [expandedFocusIndex, focusRequestedReportActionID, listRef]); - - // The rendered indexes are known here, so scrolling the row into view mounts it and keeps the message visible while it's edited. - useScrollToEditingReportAction({ - visibleReportActions: displayedReportActions, - scrollToIndex: (index) => { - // Already on screen, so its editor has mounted and taken focus — scrolling would only yank the user. - if (viewableRowIndexesRef.current.has(index)) { - return; - } - - reportScrollManager.scrollToIndex(index, {animated: false, viewPosition: 0.5}); - }, - }); - - // Find the index of the action badge target in the rendered actions list (which is what the FlatList uses as data) - const actionBadgeTargetID = reportAttributes?.actionTargetReportActionID; - const actionBadgeTargetIndex = actionBadgeTargetID - ? (reportActionIDToDisplayIndex.get(actionBadgeTargetID) ?? displayedReportActions.findIndex((action) => action.reportActionID === actionBadgeTargetID)) - : -1; - - const { - trackVerticalScrolling, - onViewableItemsChanged, - isFloatingMessageCounterVisible, - isActionBadgeAboveViewport, - scrollToBottomAndMarkReportAsRead, - scrollToActionBadgeTarget, - flushPendingScrollToBottom, - shouldBeAlignedToTop, - shouldFocusToTopOnMount, - initialScrollIndex, - initialScrollIndexParams, - maintainVisibleContentPosition, - onLoad, - } = useReportActionsScroll({ - reportID, - conciergeChat, - report, - transactionThreadReport, - parentReportAction, - sortedVisibleReportActions, - renderedVisibleReportActions: displayedReportActions, - reportActionIDToDisplayIndex, - keyExtractor, - hasScrolledOverThreshold, - markNewestActionAsRead, - completeSkippedMarkAsRead, - unreadMarkerReportActionID, - unreadMarkerReportActionIndex: displayedUnreadMarkerIndex, - hasNewerActions, - draftAutoScrollKey, - actionBadgeTargetIndex, - sortedAllReportActionsForPagination: sortedAllReportActions ?? [], - treatAsNoPaginationAnchor, - setTreatAsNoPaginationAnchor, - }); - - const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { - trackVerticalScrolling(event); - setHasScrolledOverThreshold(event.nativeEvent.contentOffset.y >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - }; - - const trackViewableRowIndexes = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { - viewableRowIndexesRef.current = new Set(info.viewableItems.map((token) => token.index).filter((index) => index !== null)); - onViewableItemsChanged(info); - }; - - const loadOlderChatsOnEndReached = () => { - if (showHiddenHistory) { - return; - } - loadOlderChats(false); - }; - - const loadNewerChatsAfterTransitions = () => { - if (!isSearchTopmostFullScreenRoute()) { - loadNewerChats(false); - return; - } - - TransitionTracker.runAfterTransitions({ - callback: () => { - requestAnimationFrame(() => loadNewerChats(false)); - }, - }); - }; - - const firstVisibleReportActionID = getFirstVisibleReportActionID(sortedReportActions, isOffline); - - const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - - // An agent account can be opened through Copilot, and the agent must not rate its own replies - const actorAccountIDs = [...new Set(renderedVisibleReportActions.map((action) => action.actorAccountID))].filter((accountID) => accountID !== currentUserAccountID); - const [customAgentAccountIDs = getEmptyArray()] = usePersonalDetailsByIDs(actorAccountIDs, getCustomAgentAccountIDs); - - // Skip inside the thread the backend opens after a thumbs down, while a Concierge answer is still streaming, and while newer actions are not loaded because the newest reply may not be in the list yet - const latestConciergeFeedbackActionIDs = - reportNameValuePairs?.conciergeFeedbackForReportActionID || isDraftPendingCompletion || hasNewerActions - ? getEmptyArray() - : getFeedbackPromptActionIDs(renderedVisibleReportActions, persistedReportActionIDs, customAgentAccountIDs); - - useFollowActionBadgeTarget({ - reportID, - actionTargetReportActionID: reportAttributes?.actionTargetReportActionID, - actionBadgeTargetIndex, - renderedVisibleReportActions: displayedReportActions, - reportActionIDToDisplayIndex, - scrollToActionBadgeTarget, - }); - - /** - * 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 = firstVisibleReportActionID === unreadMarkerReportActionID; - - const shouldUseThreadDividerLine = (() => { - const topReport = renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions.at(renderedVisibleReportActions.length - 1) : null; - - 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); - } - - if (isSupportTicket(report)) { - return true; - } - - return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); - })(); - - const renderItem = ({item: reportAction, index}: ListRenderItemInfo) => { - const canonicalIndex = canonicalIndexByReportActionID.get(reportAction.reportActionID) ?? index; - const systemMessageRun = runsByAnchorReportActionID.get(reportAction.reportActionID); - const previousReportAction = displayedReportActions.at(index + 1); - const previousRun = previousReportAction ? runsByAnchorReportActionID.get(previousReportAction.reportActionID) : undefined; - const isAfterCollapsedRun = !!previousRun && !previousRun.isExpanded; - const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; - - return ( - - {systemMessageRun && !systemMessageRun.isExpanded ? ( - expandSystemMessageRun(systemMessageRun.reportActionIDs)} - unreadMarkerReportActionID={ - unreadMarkerReportActionID && - systemMessageRun.reportActionIDs.includes(unreadMarkerReportActionID) && - (!shouldUseThreadDividerLine || !shouldHideThreadDividerLine) - ? unreadMarkerReportActionID - : undefined - } - /> - ) : ( - 1} - isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} - isLatestConciergeFeedbackAction={latestConciergeFeedbackActionIDs.includes(reportAction.reportActionID)} - reportsParentHierarchy={reportsParentHierarchy} - onExpandedSystemMessageReady={ - reportAction.reportActionID === focusRequestedReportActionID ? (target) => onExpandedSystemMessageReady(reportAction.reportActionID, target) : undefined - } - shouldUseThreadDividerLine={shouldUseThreadDividerLine} - isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} - shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} - /> - )} - {!!reportStable?.reportID && ( - - )} - - ); - }; - - // Native mobile does not render updates flatlist the changes even though component did update called. - // To notify there something changes we can use extraData prop to flatlist - const extraData = [ - shouldUseNarrowLayout ? unreadMarkerReportActionID : undefined, - isArchivedNonExpenseReport(report, isReportArchived), - draftReportActionID, - draftMessageHTML, - isDraftPendingCompletion, - latestConciergeFeedbackActionIDs, - expandedSystemMessageReportActionIDs, - focusRequestedReportActionID, - ]; - - const listHeaderComponent = ( - <> - {isConciergeHistoryExpanded && ( - { - collapseConciergeHistory(); - if (reportActionIDFromRoute) { - Navigation.setParams({reportActionID: ''}, route.key); - } - }} - /> - )} - - - ); - - const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - - const listFooterComponent = shouldShowOfflineSkeleton ? : undefined; - - const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ - policy, - report, - isTrackIntentUser, - rules, - }); - - /** - * Runs when the FlatList finishes laying out - */ - const recordTimeToMeasureItemLayout = (event: LayoutChangeEvent) => { - onLayout?.(event); - if (didLayout.current) { - return; - } - - didLayout.current = true; - - markOpenReportEnd(reportID, report, {warm: true}); - }; - - // The guard only mounts this content when the report is loaded, so this is effectively unreachable. - // It narrows `report` to non-undefined for the render below and stays a safe fallback if the report - // is cleared mid-session while the latch keeps the content mounted. - if (!report) { - return ; - } - - if (shouldShowConciergeWelcome) { - return ( - - ); - } - - return ( - <> - {/* Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view - does not carry it out of sight. Renders nothing on the layouts the composer mount serves. */} - - - - { - recordTimeToMeasureItemLayout(event); - flushPendingScrollToBottom(); - }} - onScroll={trackScrollPositionAndThreshold} - onViewableItemsChanged={trackViewableRowIndexes} - extraData={extraData} - key={listID} - overrideProps={{ - isInvertedVirtualizedList: true, - contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined, - }} - getItemType={(item) => item.actionName} - initialScrollIndex={initialScrollIndex} - initialScrollIndexParams={initialScrollIndexParams} - maintainVisibleContentPosition={maintainVisibleContentPosition} - onLoad={onLoad} - onContentSizeChange={() => { - trackVerticalScrolling(undefined); - flushPendingScrollToBottom(); - }} - /> - - - ); -} - -/** - * Public report-actions list. Thin composition that wraps the content in `ReportActionsSkeletonGuard`, - * which owns the data pipeline + skeleton decision and only mounts the content once it is ready. - */ -function ReportActionsList({reportID, conciergeChat, onLayout}: ReportActionsListProps) { - return ( - - - - ); -} - -export default ReportActionsList; diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx new file mode 100644 index 000000000000..54c8fdb9a294 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -0,0 +1,273 @@ +import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; +import InvertedFlashList from '@components/FlashList/InvertedFlashList'; +import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; +import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; + +import useEnvironment from '@hooks/useEnvironment'; +import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; +import useLocalize from '@hooks/useLocalize'; +import useMarkAsRead from '@hooks/useMarkAsRead'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; +import useSidePanelState from '@hooks/useSidePanelState'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useUnreadMarker from '@hooks/useUnreadMarker'; + +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import {shouldHidePayAction} from '@libs/ReportActionsUtils'; +import {isArchivedReport, shouldShowMarkAsDone} from '@libs/ReportUtils'; + +import type {ReportsSplitNavigatorParamList} from '@navigation/types'; + +import {useActionListContext} from '@pages/inbox/ActionListContext'; +import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; +import AskConciergeWelcome from '@pages/inbox/report/AskConciergeWelcome'; +import ConciergeChatHistoryToggle from '@pages/inbox/report/ConciergeChatHistoryToggle'; +import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; +import {useReportActionsListActions, useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; +import ReportActionsListHeader from '@pages/inbox/report/ReportActionsListHeader'; +import ReportActionsListPaddingView from '@pages/inbox/report/ReportActionsListPaddingView'; + +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 type {OnyxEntry} from 'react-native-onyx'; + +import {useRoute} from '@react-navigation/native'; +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import React, {useState} from 'react'; + +import type {ReportActionsListProps} from './types'; + +import useReportActionsListData from './useReportActionsListData'; +import useReportActionsListRenderItem from './useReportActionsListRenderItem'; +import useReportActionsListScroll from './useReportActionsListScroll'; +import useReportActionsListTelemetry from './useReportActionsListTelemetry'; + +/** + * Renders the report-actions list. Reads its data from `ReportActionsListStateContext` / `ReportActionsListActionsContext` and holds the + * UI-close hooks (`useUnreadMarker` / `useMarkAsRead` / `useReportActionsScroll`). `ReportActionsSkeletonGuard` + * mounts it only once content is ready, so those hooks never run while a skeleton shows. + */ +function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportActionsListProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isProduction} = useEnvironment(); + + const { + report, + hasOnceLoadedReportActions, + hasNewerActions, + oldestUnreadReportAction, + transactionThreadReport, + sortedReportActions: unfilteredReportActions, + sortedVisibleReportActions: unfilteredVisibleReportActions, + isConciergeHiddenHistory, + shouldShowConciergeWelcome, + isConciergeHistoryExpanded, + } = useReportActionsListState(); + + // Remove rows hidden by the renderer before calculating summary counts, headers, and scroll positions. + const sortedReportActions = report?.isWaitingOnBankAccount ? unfilteredReportActions.filter((action) => !shouldHidePayAction(action, true)) : unfilteredReportActions; + const sortedVisibleReportActions = report?.isWaitingOnBankAccount + ? unfilteredVisibleReportActions.filter((action) => !shouldHidePayAction(action, true)) + : unfilteredVisibleReportActions; + + const {handleShowPreviousMessages: onShowPreviousMessages} = useReportActionsListActions(); + + const {isOffline} = useNetwork(); + const route = useRoute>(); + const linkedReportActionID = route?.params?.reportActionID; + const {sessionStartTime} = useSidePanelState(); + const {collapseHistory: collapseConciergeHistory} = useConciergeChatHistoryActions(); + + const recordTimeToMeasureItemLayout = useReportActionsListTelemetry({reportID, onLayout}); + + useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute: linkedReportActionID}); + + // Owned here rather than by the callout, which unmounts as the layout and composer change size. + useRetireMerchantRuleSuggestionOnLeave(reportID); + + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); + const isReportArchived = !!isArchivedReport(reportNameValuePairs); + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + + const reportAttributesSelector = (value: OnyxEntry) => { + const attrs = value?.reports?.[reportID]; + if (!attrs) { + return undefined; + } + return { + actionBadge: attrs.actionBadge, + actionTargetReportActionID: attrs.actionTargetReportActionID, + brickRoadStatus: attrs.brickRoadStatus, + }; + }; + const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + selector: reportAttributesSelector, + }); + + const {getScrollOffset} = useActionListContext(); + const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + + const {unreadMarkerReportActionID} = useUnreadMarker({ + reportID, + sortedVisibleReportActions, + sortedReportActions, + oldestUnreadReportActionID: oldestUnreadReportAction?.reportActionID, + isScrolledOverThreshold: hasScrolledOverThreshold, + hasOnceLoadedReportActions: !!hasOnceLoadedReportActions, + newMessageBoundaryTime: isConciergeHiddenHistory ? sessionStartTime : undefined, + }); + + const {markNewestActionAsRead, completeSkippedMarkAsRead} = useMarkAsRead({ + reportID, + report, + transactionThreadReport, + sortedVisibleReportActions, + isScrolledToEnd: !hasScrolledOverThreshold, + hasNewerActions, + }); + + const reportActionsListData = useReportActionsListData({ + sortedVisibleReportActions, + reportNameValuePairs, + sessionStartTime, + linkedReportActionID, + unreadMarkerReportActionID, + }); + const {listData, reportActionIDToDisplayIndex, unreadMarkerListIndex, focusRequestedReportActionID, draftAutoScrollKey, isDraftPendingCompletion} = reportActionsListData; + + const {listID, listProps, shouldBeAlignedToTop, isFloatingMessageCounterVisible, isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget} = + useReportActionsListScroll({ + reportID, + conciergeChat, + sortedVisibleReportActions, + renderedVisibleReportActions: listData, + reportActionIDToDisplayIndex, + hasScrolledOverThreshold, + markNewestActionAsRead, + completeSkippedMarkAsRead, + unreadMarkerReportActionID, + unreadMarkerReportActionIndex: unreadMarkerListIndex, + draftAutoScrollKey, + focusRequestedReportActionID, + actionBadgeTargetID: reportAttributes?.actionTargetReportActionID, + linkedReportActionID, + onScrolledOverThresholdChange: setHasScrolledOverThreshold, + onLayout: recordTimeToMeasureItemLayout, + }); + + const {renderItem, extraData} = useReportActionsListRenderItem({ + ...reportActionsListData, + reportID, + reportNameValuePairs, + isReportArchived, + isOffline, + sortedReportActions, + unreadMarkerReportActionID, + linkedReportActionID, + onShowPreviousMessages, + }); + + const listHeaderComponent = ( + <> + {isConciergeHistoryExpanded && ( + { + collapseConciergeHistory(); + if (linkedReportActionID) { + Navigation.setParams({reportActionID: ''}, route.key); + } + }} + /> + )} + + + ); + + const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + + const listFooterComponent = shouldShowOfflineSkeleton ? : undefined; + + const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ + policy, + report, + isTrackIntentUser, + rules, + }); + + // The guard only mounts this content when the report is loaded, so this is effectively unreachable. + // It narrows `report` to non-undefined for the render below and stays a safe fallback if the report + // is cleared mid-session while the latch keeps the content mounted. + if (!report) { + return ; + } + + if (shouldShowConciergeWelcome) { + return ( + + ); + } + + return ( + <> + {/* Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view + does not carry it out of sight. Renders nothing on the layouts the composer mount serves. */} + + + + + + + ); +} + +export default ReportActionsListContent; diff --git a/src/pages/inbox/report/ReportActionsList/index.tsx b/src/pages/inbox/report/ReportActionsList/index.tsx new file mode 100644 index 000000000000..36db621a513e --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/index.tsx @@ -0,0 +1,26 @@ +import ReportActionsSkeletonGuard from '@pages/inbox/report/ReportActionsSkeletonGuard'; + +import React from 'react'; + +import type {ReportActionsListProps} from './types'; + +import ReportActionsListContent from './ReportActionsListContent'; + +/** + * Public report-actions list. Thin composition that wraps the content in `ReportActionsSkeletonGuard`, + * which owns the data pipeline + skeleton decision and only mounts the content once it is ready. + */ +function ReportActionsList({reportID, conciergeChat, onLayout}: ReportActionsListProps) { + return ( + + + + ); +} + +export default ReportActionsList; diff --git a/src/pages/inbox/report/ReportActionsList/types.ts b/src/pages/inbox/report/ReportActionsList/types.ts new file mode 100644 index 000000000000..583eeb6cef57 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/types.ts @@ -0,0 +1,72 @@ +import type useReportActionsScroll from '@hooks/useReportActionsScroll'; + +import type useReportActionsPresentation from '@pages/inbox/report/useReportActionsPresentation'; + +import type * as OnyxTypes from '@src/types/onyx'; + +import type {LayoutChangeEvent} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; + +import type useReportActionsListData from './useReportActionsListData'; + +type ReportActionsListProps = { + /** The ID of the report to display actions for */ + reportID: string; + + conciergeChat: OnyxEntry; + onLayout?: (event: LayoutChangeEvent) => void; +}; + +type UseReportActionsListDataParams = { + sortedVisibleReportActions: OnyxTypes.ReportAction[]; + reportNameValuePairs: OnyxEntry; + sessionStartTime: string | null; + linkedReportActionID: string | undefined; + unreadMarkerReportActionID: string | null; +}; + +type UseReportActionsListScrollParams = Pick< + Parameters[0], + | 'reportID' + | 'conciergeChat' + | 'sortedVisibleReportActions' + | 'renderedVisibleReportActions' + | 'hasScrolledOverThreshold' + | 'markNewestActionAsRead' + | 'completeSkippedMarkAsRead' + | 'unreadMarkerReportActionID' + | 'unreadMarkerReportActionIndex' + | 'draftAutoScrollKey' +> & + Pick, 'reportActionIDToDisplayIndex' | 'focusRequestedReportActionID'> & + Pick & + Pick & { + actionBadgeTargetID: string | undefined; + onScrolledOverThresholdChange: (isOverThreshold: boolean) => void; + }; + +type UseReportActionsListRenderItemParams = Pick< + ReturnType, + | 'renderedVisibleReportActions' + | 'listData' + | 'canonicalIndexByReportActionID' + | 'runsByAnchorReportActionID' + | 'expandedSystemMessageReportActionIDs' + | 'expandSystemMessageRun' + | 'focusRequestedReportActionID' + | 'onExpandedSystemMessageReady' + | 'draftMessageHTML' + | 'draftReportActionID' + | 'isDraftPendingCompletion' +> & + Pick & + Pick & { + isReportArchived: boolean; + isOffline: boolean; + sortedReportActions: OnyxTypes.ReportAction[]; + onShowPreviousMessages: () => void; + }; + +type UseReportActionsListTelemetryParams = Pick; + +export type {ReportActionsListProps, UseReportActionsListDataParams, UseReportActionsListScrollParams, UseReportActionsListRenderItemParams, UseReportActionsListTelemetryParams}; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts new file mode 100644 index 000000000000..8861054b8790 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts @@ -0,0 +1,148 @@ +import {getReportActionMessage, isNewerReportAction, isTransactionThread} from '@libs/ReportActionsUtils'; +import {isExpenseReport, isIOUReport, isInvoiceReport, isResolvedSupportTicket, isSupportTicket} from '@libs/ReportUtils'; + +import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; +import {useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; +import useReportActionsPresentation from '@pages/inbox/report/useReportActionsPresentation'; + +import CONST from '@src/CONST'; + +import {useEffect} from 'react'; + +import type {UseReportActionsListDataParams} from './types'; + +/** Prepares the rendered report rows, including streamed Concierge drafts, support surveys, and collapsed system messages. */ +function useReportActionsListData({sortedVisibleReportActions, reportNameValuePairs, sessionStartTime, linkedReportActionID, unreadMarkerReportActionID}: UseReportActionsListDataParams) { + const {report, sortedAllReportActions, parentReportAction, isConciergeHiddenHistory, showFullHistory} = useReportActionsListState(); + const {draftReportAction, isDraftPendingCompletion} = useConciergeDraft(); + const {clearDraft, revealDraftFromReportAction} = useConciergeDraftActions(); + const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; + + const persistedDraftReportAction = draftReportAction + ? (sortedAllReportActions ?? sortedVisibleReportActions).find( + (action) => action.reportActionID === draftReportAction.reportActionID && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + ) + : undefined; + + const shouldHideSupportTicketSurvey = isSupportTicket(report) && (!isResolvedSupportTicket(report) || !!reportNameValuePairs?.reopenedAsReportID); + const latestResolvedSupportTicketAction = sortedAllReportActions?.find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED); + const latestSupportTicketSurveyAction = latestResolvedSupportTicketAction + ? sortedAllReportActions?.find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY && action.created >= latestResolvedSupportTicketAction.created) + : undefined; + + const visibleReportActions = (() => { + if (!draftReportAction) { + return sortedVisibleReportActions; + } + + if (showHiddenHistory && sessionStartTime && draftReportAction.created < sessionStartTime) { + return sortedVisibleReportActions; + } + + // A completed reveal belongs to the saved action, even when pagination keeps that action off screen. + if (persistedDraftReportAction && !isDraftPendingCompletion) { + return sortedVisibleReportActions; + } + + // Insert the synthetic draft into the already-descending render list without treating it as a persisted report action. + for (const [index, action] of sortedVisibleReportActions.entries()) { + if (action.reportActionID === draftReportAction.reportActionID) { + // Completed local replies can retain their pending add flag after the server merges followups. + if (!isDraftPendingCompletion) { + return sortedVisibleReportActions; + } + + const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; + visibleReportActionsWithDraft[index] = draftReportAction; + return visibleReportActionsWithDraft; + } + if (isNewerReportAction(draftReportAction, action)) { + const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; + visibleReportActionsWithDraft.splice(index, 0, draftReportAction); + return visibleReportActionsWithDraft; + } + } + + const visibleReportActionsWithDraft = [...sortedVisibleReportActions]; + visibleReportActionsWithDraft.push(draftReportAction); + return visibleReportActionsWithDraft; + })(); + + const shouldFilterSupportTicketSurveys = + isSupportTicket(report) && + visibleReportActions.some( + (action) => + action.actionName === CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY && + (shouldHideSupportTicketSurvey || action.reportActionID !== latestSupportTicketSurveyAction?.reportActionID), + ); + const renderedVisibleReportActions = shouldFilterSupportTicketSurveys + ? visibleReportActions.filter( + (action) => + action.actionName !== CONST.REPORT.ACTIONS.TYPE.SUPPORT_SURVEY || + (!shouldHideSupportTicketSurvey && action.reportActionID === latestSupportTicketSurveyAction?.reportActionID), + ) + : visibleReportActions; + + const shouldCollapseSystemMessages = isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report) || isTransactionThread(parentReportAction); + const { + displayReportActions, + runsByAnchorReportActionID, + reportActionIDToDisplayIndex, + expandedSystemMessageReportActionIDs, + unreadMarkerReportActionIndex, + expandSystemMessageRun, + focusRequestedReportActionID, + onExpandedSystemMessageReady, + } = useReportActionsPresentation({ + visibleReportActions: shouldCollapseSystemMessages ? renderedVisibleReportActions : [], + linkedReportActionID, + unreadMarkerReportActionID, + }); + const listData = shouldCollapseSystemMessages ? displayReportActions : renderedVisibleReportActions; + const canonicalIndexByReportActionID = new Map(renderedVisibleReportActions.map((action, index) => [action.reportActionID, index])); + const unreadMarkerListIndex = shouldCollapseSystemMessages + ? unreadMarkerReportActionIndex + : renderedVisibleReportActions.findIndex((action) => action.reportActionID === unreadMarkerReportActionID); + + const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; + const draftReportActionID = draftReportAction?.reportActionID; + const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; + const draftAutoScrollKey = isSyntheticDraftVisible ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; + + useEffect(() => { + if (!draftReportAction || isSyntheticDraftVisible) { + return; + } + + clearDraft(); + }, [clearDraft, draftReportAction, isSyntheticDraftVisible]); + + useEffect(() => { + if (!isDraftPendingCompletion || !draftReportAction || !persistedDraftReportAction) { + return; + } + + // The persisted action is the durable completion signal when a terminal Pusher event is missed. + // Reconcile by action ID even when its HTML is byte-identical to the last streamed draft. + revealDraftFromReportAction(persistedDraftReportAction); + }, [draftReportAction, isDraftPendingCompletion, persistedDraftReportAction, revealDraftFromReportAction]); + + return { + renderedVisibleReportActions, + listData, + canonicalIndexByReportActionID, + runsByAnchorReportActionID, + reportActionIDToDisplayIndex, + expandedSystemMessageReportActionIDs, + unreadMarkerListIndex, + expandSystemMessageRun, + focusRequestedReportActionID, + onExpandedSystemMessageReady, + draftMessageHTML, + draftReportActionID, + draftAutoScrollKey, + isDraftPendingCompletion, + }; +} + +export default useReportActionsListData; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx b/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx new file mode 100644 index 000000000000..3de24b341f6e --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx @@ -0,0 +1,215 @@ +import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; +import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import { + getFirstVisibleReportActionID, + getFeedbackPromptActionIDs, + isConsecutiveActionMadeByPreviousActor, + isDeletedParentAction, + isReversedTransaction, + isTransactionThread, +} from '@libs/ReportActionsUtils'; +import { + chatIncludesChronosWithID, + isArchivedNonExpenseReport, + isCanceledTaskReport, + isExpenseReport, + isHarvestCreatedExpenseReport, + isInvoiceReport, + isIOUReport, + isTaskReport, + isSupportTicket, +} from '@libs/ReportUtils'; + +import CollapsedSystemMessages from '@pages/inbox/report/CollapsedSystemMessages'; +import {ReportActionPositionContextProvider} from '@pages/inbox/report/ReportActionIndexContext'; +import {useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; +import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; +import ShowPreviousMessagesButton from '@pages/inbox/report/ShowPreviousMessagesButton'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {getStableReportSelector} from '@src/selectors/Report'; +import type * as OnyxTypes from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; + +import type {ListRenderItemInfo} from '@shopify/flash-list'; + +import {getCustomAgentAccountIDs} from '@selectors/AgentZeroChat'; +import {accountIDSelector} from '@selectors/Session'; +import React from 'react'; + +import type {UseReportActionsListRenderItemParams} from './types'; + +/** Builds the report-actions row renderer and the `extraData` values that must re-render rows when they change. */ +function useReportActionsListRenderItem({ + reportID, + reportNameValuePairs, + isReportArchived, + isOffline, + sortedReportActions, + renderedVisibleReportActions, + listData, + canonicalIndexByReportActionID, + runsByAnchorReportActionID, + expandedSystemMessageReportActionIDs, + expandSystemMessageRun, + focusRequestedReportActionID, + onExpandedSystemMessageReady, + draftMessageHTML, + draftReportActionID, + isDraftPendingCompletion, + unreadMarkerReportActionID, + linkedReportActionID, + onShowPreviousMessages, +}: UseReportActionsListRenderItemParams) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const { + report, + hasNewerActions, + transactionThreadReport, + parentReportActionForTransactionThread, + parentReportAction, + isConciergeHiddenHistory, + showFullHistory, + hasPreviousMessages, + persistedReportActionIDs, + } = useReportActionsListState(); + const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; + + const [reportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getStableReportSelector}); + const [chatReportStable] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportStable?.chatReportID)}`, {selector: getStableReportSelector}); + const reportsParentHierarchy = useReportsParentHierarchy(reportID); + const isHarvestCreatedExpenseReportAction = isHarvestCreatedExpenseReport(reportNameValuePairs?.origin, reportNameValuePairs?.originalID); + + const firstVisibleReportActionID = getFirstVisibleReportActionID(sortedReportActions, isOffline); + + const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + + // An agent account can be opened through Copilot, and the agent must not rate its own replies + const actorAccountIDs = [...new Set(renderedVisibleReportActions.map((action) => action.actorAccountID))].filter((accountID) => accountID !== currentUserAccountID); + const [customAgentAccountIDs = getEmptyArray()] = usePersonalDetailsByIDs(actorAccountIDs, getCustomAgentAccountIDs); + + // Skip inside the thread the backend opens after a thumbs down, while a Concierge answer is still streaming, and while newer actions are not loaded because the newest reply may not be in the list yet + const latestConciergeFeedbackActionIDs = + reportNameValuePairs?.conciergeFeedbackForReportActionID || isDraftPendingCompletion || hasNewerActions + ? getEmptyArray() + : getFeedbackPromptActionIDs(renderedVisibleReportActions, persistedReportActionIDs, customAgentAccountIDs); + + /** + * 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 = firstVisibleReportActionID === unreadMarkerReportActionID; + + const shouldUseThreadDividerLine = (() => { + const topReport = renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions.at(renderedVisibleReportActions.length - 1) : null; + + 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); + } + + if (isSupportTicket(report)) { + return true; + } + + return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); + })(); + + const renderItem = ({item: reportAction, index}: ListRenderItemInfo) => { + const canonicalIndex = canonicalIndexByReportActionID.get(reportAction.reportActionID) ?? index; + const systemMessageRun = runsByAnchorReportActionID.get(reportAction.reportActionID); + const previousReportAction = listData.at(index + 1); + const previousRun = previousReportAction ? runsByAnchorReportActionID.get(previousReportAction.reportActionID) : undefined; + const isAfterCollapsedRun = !!previousRun && !previousRun.isExpanded; + const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + + return ( + + {systemMessageRun && !systemMessageRun.isExpanded ? ( + expandSystemMessageRun(systemMessageRun.reportActionIDs)} + unreadMarkerReportActionID={ + unreadMarkerReportActionID && + systemMessageRun.reportActionIDs.includes(unreadMarkerReportActionID) && + (!shouldUseThreadDividerLine || !shouldHideThreadDividerLine) + ? unreadMarkerReportActionID + : undefined + } + /> + ) : ( + 1} + isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} + isLatestConciergeFeedbackAction={latestConciergeFeedbackActionIDs.includes(reportAction.reportActionID)} + reportsParentHierarchy={reportsParentHierarchy} + onExpandedSystemMessageReady={ + reportAction.reportActionID === focusRequestedReportActionID ? (target) => onExpandedSystemMessageReady(reportAction.reportActionID, target) : undefined + } + shouldUseThreadDividerLine={shouldUseThreadDividerLine} + isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} + shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} + /> + )} + {!!reportStable?.reportID && ( + + )} + + ); + }; + + // Native mobile does not render updates flatlist the changes even though component did update called. + // To notify there something changes we can use extraData prop to flatlist + const extraData = [ + shouldUseNarrowLayout ? unreadMarkerReportActionID : undefined, + isArchivedNonExpenseReport(report, isReportArchived), + draftReportActionID, + draftMessageHTML, + isDraftPendingCompletion, + latestConciergeFeedbackActionIDs, + expandedSystemMessageReportActionIDs, + focusRequestedReportActionID, + ]; + + return {renderItem, extraData}; +} + +export default useReportActionsListRenderItem; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts new file mode 100644 index 000000000000..855c227ebda8 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts @@ -0,0 +1,224 @@ +import useReportActionsScroll from '@hooks/useReportActionsScroll'; +import useReportScrollManager from '@hooks/useReportScrollManager'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import {useActionListRef} from '@pages/inbox/ActionListContext'; +import {useReportActionsListActions, useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; +import useFollowActionBadgeTarget from '@pages/inbox/report/useFollowActionBadgeTarget'; +import useScrollToEditingReportAction from '@pages/inbox/report/useScrollToEditingReportAction'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; + +import {useEffect, useRef} from 'react'; + +import type {UseReportActionsListScrollParams} from './types'; + +/** + * 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 { + // A report has exactly one CREATED action. Using a stable key lets FlashList recycle the same cell + // when the optimistic CREATED is swapped for the server one, avoiding a remount-induced scroll jump. + if (item.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + return CONST.REPORT.ACTIONS.TYPE.CREATED; + } + return item.reportActionID; +} + +/** Owns initial positioning, pagination, unread tracking, and scroll events for the report-actions list. */ +function useReportActionsListScroll({ + reportID, + conciergeChat, + sortedVisibleReportActions, + renderedVisibleReportActions: listData, + reportActionIDToDisplayIndex, + hasScrolledOverThreshold, + markNewestActionAsRead, + completeSkippedMarkAsRead, + unreadMarkerReportActionID, + unreadMarkerReportActionIndex, + draftAutoScrollKey, + focusRequestedReportActionID, + actionBadgeTargetID, + linkedReportActionID, + onScrolledOverThresholdChange, + onLayout, +}: UseReportActionsListScrollParams) { + const {windowHeight} = useWindowDimensions(); + const { + report, + hasOnceLoadedReportActions, + hasNewerActions, + sortedAllReportActions, + oldestUnreadReportAction, + transactionThreadReport, + parentReportAction, + treatAsNoPaginationAnchor, + isConciergeHiddenHistory, + showFullHistory, + } = useReportActionsListState(); + const {setTreatAsNoPaginationAnchor, loadOlderChats, loadNewerChats} = useReportActionsListActions(); + const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; + + // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. + const listID = [reportID, linkedReportActionID, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); + + const listRef = useActionListRef(); + const reportScrollManager = useReportScrollManager(); + + // Which rows are on screen, tracked so an edit started on a message the user can already see doesn't move the list. + const viewableRowIndexesRef = useRef>(new Set()); + + const expandedFocusIndex = focusRequestedReportActionID ? (reportActionIDToDisplayIndex.get(focusRequestedReportActionID) ?? -1) : -1; + useEffect(() => { + if (expandedFocusIndex < 0 || viewableRowIndexesRef.current.has(expandedFocusIndex)) { + return; + } + + // An off-window revealed message must mount before it can hand off focus. + const frame = requestAnimationFrame(() => { + listRef.current?.scrollToIndex({index: expandedFocusIndex, animated: false, viewPosition: 0.5}); + }); + return () => cancelAnimationFrame(frame); + }, [expandedFocusIndex, focusRequestedReportActionID, listRef]); + + // The rendered indexes are known here, so scrolling the row into view mounts it and keeps the message visible while it's edited. + useScrollToEditingReportAction({ + visibleReportActions: listData, + scrollToIndex: (index) => { + // Already on screen, so its editor has mounted and taken focus — scrolling would only yank the user. + if (viewableRowIndexesRef.current.has(index)) { + return; + } + + reportScrollManager.scrollToIndex(index, {animated: false, viewPosition: 0.5}); + }, + }); + + // Find the index of the action badge target in the rendered actions list (which is what the FlatList uses as data) + const actionBadgeTargetIndex = actionBadgeTargetID + ? (reportActionIDToDisplayIndex.get(actionBadgeTargetID) ?? listData.findIndex((action) => action.reportActionID === actionBadgeTargetID)) + : -1; + + const { + trackVerticalScrolling, + onViewableItemsChanged, + isFloatingMessageCounterVisible, + isActionBadgeAboveViewport, + scrollToBottomAndMarkReportAsRead, + scrollToActionBadgeTarget, + flushPendingScrollToBottom, + shouldBeAlignedToTop, + shouldFocusToTopOnMount, + initialScrollIndex, + initialScrollIndexParams, + maintainVisibleContentPosition, + onLoad, + } = useReportActionsScroll({ + reportID, + conciergeChat, + report, + transactionThreadReport, + parentReportAction, + sortedVisibleReportActions, + renderedVisibleReportActions: listData, + reportActionIDToDisplayIndex, + keyExtractor, + hasScrolledOverThreshold, + markNewestActionAsRead, + completeSkippedMarkAsRead, + unreadMarkerReportActionID, + unreadMarkerReportActionIndex, + hasNewerActions, + draftAutoScrollKey, + actionBadgeTargetIndex, + sortedAllReportActionsForPagination: sortedAllReportActions ?? [], + treatAsNoPaginationAnchor, + setTreatAsNoPaginationAnchor, + }); + + const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { + trackVerticalScrolling(event); + onScrolledOverThresholdChange(event.nativeEvent.contentOffset.y >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + }; + + const trackViewableRowIndexes = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + viewableRowIndexesRef.current = new Set(info.viewableItems.map((token) => token.index).filter((index) => index !== null)); + onViewableItemsChanged(info); + }; + + const loadOlderChatsOnEndReached = () => { + if (showHiddenHistory) { + return; + } + loadOlderChats(false); + }; + + const loadNewerChatsAfterTransitions = () => { + if (!isSearchTopmostFullScreenRoute()) { + loadNewerChats(false); + return; + } + + TransitionTracker.runAfterTransitions({ + callback: () => { + requestAnimationFrame(() => loadNewerChats(false)); + }, + }); + }; + + useFollowActionBadgeTarget({ + reportID, + actionTargetReportActionID: actionBadgeTargetID, + actionBadgeTargetIndex, + renderedVisibleReportActions: listData, + reportActionIDToDisplayIndex, + scrollToActionBadgeTarget, + }); + + return { + listID, + shouldBeAlignedToTop, + isFloatingMessageCounterVisible, + isActionBadgeAboveViewport, + scrollToBottomAndMarkReportAsRead, + scrollToActionBadgeTarget, + listProps: { + ref: listRef, + keyExtractor, + getItemType: (item: OnyxTypes.ReportAction) => item.actionName, + onEndReached: loadOlderChatsOnEndReached, + onEndReachedThreshold: 0.75, + onStartReached: loadNewerChatsAfterTransitions, + onStartReachedThreshold: 0.75, + onLayout: (event: LayoutChangeEvent) => { + onLayout?.(event); + flushPendingScrollToBottom(); + }, + onScroll: trackScrollPositionAndThreshold, + onViewableItemsChanged: trackViewableRowIndexes, + overrideProps: { + isInvertedVirtualizedList: true, + contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined, + }, + initialScrollIndex, + initialScrollIndexParams, + maintainVisibleContentPosition, + onLoad, + onContentSizeChange: () => { + trackVerticalScrolling(undefined); + flushPendingScrollToBottom(); + }, + }, + }; +} + +export default useReportActionsListScroll; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts new file mode 100644 index 000000000000..230dc8d7737b --- /dev/null +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts @@ -0,0 +1,35 @@ +import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; + +import {useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; + +import type {LayoutChangeEvent} from 'react-native'; + +import {useEffect, useRef} from 'react'; + +import type {UseReportActionsListTelemetryParams} from './types'; + +/** Returns the list layout handler, which completes the warm open-report span on the first layout. */ +function useReportActionsListTelemetry({reportID, onLayout}: UseReportActionsListTelemetryParams) { + const {report} = useReportActionsListState(); + const didLayout = useRef(false); + + useEffect(() => { + didLayout.current = false; + }, [reportID]); + + /** + * Runs when the FlatList finishes laying out + */ + return (event: LayoutChangeEvent) => { + onLayout?.(event); + if (didLayout.current) { + return; + } + + didLayout.current = true; + + markOpenReportEnd(reportID, report, {warm: true}); + }; +} + +export default useReportActionsListTelemetry;