diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index f44f1d428817..1f33e7af5fe8 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -103,6 +103,9 @@ type UseReportActionsScrollResult = { /** Whether the action badge target is above the viewport */ isActionBadgeAboveViewport: boolean; + /** Whether the action badge target is below the viewport */ + isActionBadgeBelowViewport: boolean; + /** Scrolls to the newest action and marks the report as read */ scrollToBottomAndMarkReportAsRead: () => void; @@ -204,21 +207,28 @@ function useReportActionsScroll({ ...(shouldFocusToTopOnMount ? {autoscrollToBottomThreshold: shouldAutoscrollToBottom ? CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD : 0, animateAutoScrollToBottom: false} : {}), }; - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, isActionBadgeAboveViewport, trackVerticalScrolling, onViewableItemsChanged, updatePillVisibility} = - useReportUnreadMessageScrollTracking({ - reportID, - currentVerticalScrollingOffsetRef: scrollOffsetRef, - onUnreadActionVisible: completeSkippedMarkAsRead, - hasNewerActions, - unreadMarkerReportActionIndex, - isInverted: true, - shouldDisablePillTracking, - onTrackScrolling: (event: NativeSyntheticEvent) => { - scrollOffsetRef.current = event.nativeEvent.contentOffset.y; - }, - actionBadgeTargetIndex, - shouldBeAlignedToTop, - }); + const { + isFloatingMessageCounterVisible, + setIsFloatingMessageCounterVisible, + isActionBadgeAboveViewport, + isActionBadgeBelowViewport, + trackVerticalScrolling, + onViewableItemsChanged, + updatePillVisibility, + } = useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: scrollOffsetRef, + onUnreadActionVisible: completeSkippedMarkAsRead, + hasNewerActions, + unreadMarkerReportActionIndex, + isInverted: true, + shouldDisablePillTracking, + onTrackScrolling: (event: NativeSyntheticEvent) => { + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, + actionBadgeTargetIndex, + shouldBeAlignedToTop, + }); const {isScrollToBottomEnabled, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom} = useReportActionsNewActionLiveTail({ reportID, @@ -424,6 +434,7 @@ function useReportActionsScroll({ onViewableItemsChanged, isFloatingMessageCounterVisible, isActionBadgeAboveViewport, + isActionBadgeBelowViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, flushPendingScrollToBottom, diff --git a/src/pages/inbox/report/FloatingMessageCounter.tsx b/src/pages/inbox/report/FloatingMessageCounter.tsx index f19df402b9dc..fa146f32f1c4 100644 --- a/src/pages/inbox/report/FloatingMessageCounter.tsx +++ b/src/pages/inbox/report/FloatingMessageCounter.tsx @@ -86,6 +86,9 @@ type FloatingMessageCounterProps = { /** The brick road status for the action badge ('error' = red, 'info' = green) */ actionBadgeBrickRoadStatus?: ValueOf; + /** Whether the action badge target is below the current scroll position (pill points down instead of up) */ + isActionBadgeBelowViewport?: boolean; + /** Callback when the action badge pill is clicked */ onActionBadgePress?: () => void; @@ -102,6 +105,7 @@ function FloatingMessageCounter({ hasNewMessages, actionBadge, actionBadgeBrickRoadStatus, + isActionBadgeBelowViewport = false, onActionBadgePress, isMarkAsDone, }: FloatingMessageCounterProps) { @@ -152,7 +156,7 @@ function FloatingMessageCounter({ ; } + // Show the action badge pill whenever the target is out of view — above or below the current scroll position. + const shouldShowActionBadgePill = isActionBadgeAboveViewport || isActionBadgeBelowViewport; + return ( <> diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 6a0385d271dc..3d16448c8bd9 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -52,6 +52,7 @@ export default function useReportUnreadMessageScrollTracking({ }: Args) { const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(false); const [isActionBadgeAboveViewport, setIsActionBadgeAboveViewport] = useState(false); + const [isActionBadgeBelowViewport, setIsActionBadgeBelowViewport] = useState(false); const isFocused = useIsFocused(); const ref = useRef<{ previousViewableItems: ViewToken[]; @@ -159,15 +160,20 @@ export default function useReportUnreadMessageScrollTracking({ ref.current.onUnreadActionVisible(); } - // Track whether the action badge target is above the viewport (i.e., not visible and at a higher index in the inverted list) + // Track whether the action badge target is above or below the viewport (i.e., not visible), so the pill can point + // toward it with the correct arrow direction. const badgeTargetIndex = ref.current.actionBadgeTargetIndex; if (badgeTargetIndex !== -1) { - // In an inverted list, higher indexes are "above" (older messages). The target is above the viewport - // when its index is greater than the max visible index. + // In an inverted list, higher indexes are "above" (older messages) and lower indexes are "below" (newer messages). + // The target is above the viewport when its index is greater than the max visible index, and below when its index + // is less than the min visible index. const isAbove = isInverted ? badgeTargetIndex > maxIndex : badgeTargetIndex < minIndex; + const isBelow = isInverted ? badgeTargetIndex < minIndex : badgeTargetIndex > maxIndex; setIsActionBadgeAboveViewport(isAbove); + setIsActionBadgeBelowViewport(isBelow); } else { setIsActionBadgeAboveViewport(false); + setIsActionBadgeBelowViewport(false); } // FlatList requires a stable onViewableItemsChanged callback for optimal performance. @@ -195,6 +201,7 @@ export default function useReportUnreadMessageScrollTracking({ isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, isActionBadgeAboveViewport, + isActionBadgeBelowViewport, trackVerticalScrolling, onViewableItemsChanged, updatePillVisibility, diff --git a/tests/unit/FloatingMessageCounterTest.tsx b/tests/unit/FloatingMessageCounterTest.tsx index 11d1601860f3..d627bde83255 100644 --- a/tests/unit/FloatingMessageCounterTest.tsx +++ b/tests/unit/FloatingMessageCounterTest.tsx @@ -137,6 +137,27 @@ describe('FloatingMessageCounter', () => { expect(screen.getByText('Submit')).toBeTruthy(); }); + it('renders the action badge pill and calls onActionBadgePress when the target is below the viewport (down arrow)', () => { + const onActionBadgePressMock = jest.fn(); + render( + , + ); + + expect(screen.getByText('Approve')).toBeTruthy(); + expect(screen.queryByText('New messages')).toBeNull(); + + fireEvent.press(screen.getByText('Approve')); + expect(onActionBadgePressMock).toHaveBeenCalledTimes(1); + }); + it('does not show action badge pill when only actionBadge is provided without actionBadgeBrickRoadStatus', () => { render( { expect(result.current.isActionBadgeAboveViewport).toBe(true); }); + it('returns isActionBadgeBelowViewport as false initially', () => { + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + unreadMarkerReportActionIndex: -1, + isInverted: true, + actionBadgeTargetIndex: -1, + }), + ); + + expect(result.current.isActionBadgeBelowViewport).toBe(false); + }); + + it('returns isActionBadgeBelowViewport as true when action badge target is below the viewport in inverted list', () => { + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + unreadMarkerReportActionIndex: -1, + isInverted: true, + actionBadgeTargetIndex: 1, + }), + ); + + // When viewable items are at indexes 3-5, the target at index 1 is below the viewport (lower index = below in inverted list) + act(() => { + result.current.onViewableItemsChanged({ + viewableItems: [ + {index: 3, key: 'reportActions_3', isViewable: true, item: {}}, + {index: 4, key: 'reportActions_4', isViewable: true, item: {}}, + {index: 5, key: 'reportActions_5', isViewable: true, item: {}}, + ], + changed: [], + }); + }); + + expect(result.current.isActionBadgeBelowViewport).toBe(true); + expect(result.current.isActionBadgeAboveViewport).toBe(false); + }); + + it('returns isActionBadgeBelowViewport as false when action badge target is visible in viewport', () => { + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + unreadMarkerReportActionIndex: -1, + isInverted: true, + actionBadgeTargetIndex: 2, + }), + ); + + // When viewable items include index 2, the target is visible + act(() => { + result.current.onViewableItemsChanged({ + viewableItems: [ + {index: 1, key: 'reportActions_1', isViewable: true, item: {}}, + {index: 2, key: 'reportActions_2', isViewable: true, item: {}}, + {index: 3, key: 'reportActions_3', isViewable: true, item: {}}, + ], + changed: [], + }); + }); + + expect(result.current.isActionBadgeBelowViewport).toBe(false); + expect(result.current.isActionBadgeAboveViewport).toBe(false); + }); + it('recalculates action badge visibility when actionBadgeTargetIndex changes', () => { const offsetRef = {current: 0}; let actionBadgeTargetIndex = -1;