Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 26 additions & 15 deletions src/hooks/useReportActionsScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<NativeScrollEvent>) => {
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<NativeScrollEvent>) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
},
actionBadgeTargetIndex,
shouldBeAlignedToTop,
});

const {isScrollToBottomEnabled, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom} = useReportActionsNewActionLiveTail({
reportID,
Expand Down Expand Up @@ -424,6 +434,7 @@ function useReportActionsScroll({
onViewableItemsChanged,
isFloatingMessageCounterVisible,
isActionBadgeAboveViewport,
isActionBadgeBelowViewport,
scrollToBottomAndMarkReportAsRead,
scrollToActionBadgeTarget,
flushPendingScrollToBottom,
Expand Down
6 changes: 5 additions & 1 deletion src/pages/inbox/report/FloatingMessageCounter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ type FloatingMessageCounterProps = {
/** The brick road status for the action badge ('error' = red, 'info' = green) */
actionBadgeBrickRoadStatus?: ValueOf<typeof CONST.BRICK_ROAD_INDICATOR_STATUS>;

/** 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;

Expand All @@ -102,6 +105,7 @@ function FloatingMessageCounter({
hasNewMessages,
actionBadge,
actionBadgeBrickRoadStatus,
isActionBadgeBelowViewport = false,
onActionBadgePress,
isMarkAsDone,
}: FloatingMessageCounterProps) {
Expand Down Expand Up @@ -152,7 +156,7 @@ function FloatingMessageCounter({
<FloatingPillButton
variant={isError ? CONST.BUTTON_VARIANT.DANGER : CONST.BUTTON_VARIANT.SUCCESS}
onPress={onActionBadgePress}
icon={icons.UpArrow}
icon={isActionBadgeBelowViewport ? icons.DownArrow : icons.UpArrow}
iconFill={theme.textLight}
label={actionBadgeText}
textStyle={styles.textWhite}
Expand Down
9 changes: 7 additions & 2 deletions src/pages/inbox/report/ReportActionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent
onViewableItemsChanged,
isFloatingMessageCounterVisible,
isActionBadgeAboveViewport,
isActionBadgeBelowViewport,
scrollToBottomAndMarkReportAsRead,
scrollToActionBadgeTarget,
flushPendingScrollToBottom,
Expand Down Expand Up @@ -443,14 +444,18 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent
return <ReportActionsSkeletonView />;
}

// Show the action badge pill whenever the target is out of view — above or below the current scroll position.
const shouldShowActionBadgePill = isActionBadgeAboveViewport || isActionBadgeBelowViewport;

return (
<>
<FloatingMessageCounter
hasNewMessages={!!unreadMarkerReportActionID}
isActive={isFloatingMessageCounterVisible}
onClick={scrollToBottomAndMarkReportAsRead}
actionBadge={!isProduction && isActionBadgeAboveViewport ? reportAttributes?.actionBadge : undefined}
actionBadgeBrickRoadStatus={!isProduction && isActionBadgeAboveViewport ? reportAttributes?.brickRoadStatus : undefined}
actionBadge={!isProduction && shouldShowActionBadgePill ? reportAttributes?.actionBadge : undefined}
actionBadgeBrickRoadStatus={!isProduction && shouldShowActionBadgePill ? reportAttributes?.brickRoadStatus : undefined}
isActionBadgeBelowViewport={isActionBadgeBelowViewport}
onActionBadgePress={scrollToActionBadgeTarget}
isMarkAsDone={shouldUseMarkAsDoneCopy}
/>
Expand Down
13 changes: 10 additions & 3 deletions src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -195,6 +201,7 @@ export default function useReportUnreadMessageScrollTracking({
isFloatingMessageCounterVisible,
setIsFloatingMessageCounterVisible,
isActionBadgeAboveViewport,
isActionBadgeBelowViewport,
trackVerticalScrolling,
onViewableItemsChanged,
updatePillVisibility,
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/FloatingMessageCounterTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FloatingMessageCounter
isActive
hasNewMessages
onClick={jest.fn()}
actionBadge={CONST.REPORT.ACTION_BADGE.APPROVE}
actionBadgeBrickRoadStatus={CONST.BRICK_ROAD_INDICATOR_STATUS.INFO}
isActionBadgeBelowViewport
onActionBadgePress={onActionBadgePressMock}
/>,
);

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(
<FloatingMessageCounter
Expand Down
80 changes: 80 additions & 0 deletions tests/unit/useReportUnreadMessageScrollTrackingTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,86 @@ describe('useReportUnreadMessageScrollTracking', () => {
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;
Expand Down
Loading