Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/hooks/useReportActionsScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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 unread marker when available, otherwise to the newest action, and marks the report as read when appropriate */
scrollToBottomAndMarkReportAsRead: () => void;

Expand Down Expand Up @@ -227,6 +230,8 @@ function useReportActionsScroll({
setIsFloatingMessageCounterVisible,
isActionBadgeAboveViewport,
setIsActionBadgeAboveViewport,
isActionBadgeBelowViewport,
setIsActionBadgeBelowViewport,
trackVerticalScrolling,
onViewableItemsChanged,
updatePillVisibility,
Expand Down Expand Up @@ -440,6 +445,7 @@ function useReportActionsScroll({
// The expanded composer covers the list, so the target never becomes viewable to hide the pill
if (isComposerFullSize) {
setIsActionBadgeAboveViewport(false);
setIsActionBadgeBelowViewport(false);
setIsFloatingMessageCounterVisible(false);
}
// `animated` is explicit because native defaults to an instant jump, which would teleport the list and lose the user's place.
Expand Down Expand Up @@ -508,6 +514,7 @@ function useReportActionsScroll({
onViewableItemsChanged,
isFloatingMessageCounterVisible,
isActionBadgeAboveViewport,
isActionBadgeBelowViewport,
scrollToBottomAndMarkReportAsRead,
scrollToActionBadgeTarget,
flushPendingScrollToBottom,
Expand Down
12 changes: 9 additions & 3 deletions src/pages/inbox/report/FloatingMessageCounter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ type FloatingMessageCounterProps = {
/** Callback when the action badge pill is clicked */
onActionBadgePress?: () => void;

/** Whether the action badge target is below the viewport, so the pill points down instead of up */
isActionBadgeTargetBelow?: boolean;

/** Whether to show "Mark as done" copy instead of "Submit" copy for track-intent users */
shouldShowMarkAsDoneCopy?: boolean;
};
Expand All @@ -99,6 +102,7 @@ function FloatingMessageCounter({
actionBadge,
actionBadgeBrickRoadStatus,
onActionBadgePress,
isActionBadgeTargetBelow = false,
shouldShowMarkAsDoneCopy,
}: FloatingMessageCounterProps) {
const icons = useMemoizedLazyExpensifyIcons(['DownArrow', 'UpArrow']);
Expand All @@ -111,11 +115,13 @@ function FloatingMessageCounter({
const isVisible = isActive || shouldShowActionBadgePill;

// Keep the last shown pill while it slides out so hiding the action badge pill does not flash the Latest messages pill
const visibleActionBadge = shouldShowActionBadgePill ? {actionBadge, actionBadgeBrickRoadStatus} : undefined;
const visibleActionBadge = shouldShowActionBadgePill ? {actionBadge, actionBadgeBrickRoadStatus, isActionBadgeTargetBelow} : undefined;
const [lastVisibleActionBadge, setLastVisibleActionBadge] = useState(visibleActionBadge);
if (
isVisible &&
(lastVisibleActionBadge?.actionBadge !== visibleActionBadge?.actionBadge || lastVisibleActionBadge?.actionBadgeBrickRoadStatus !== visibleActionBadge?.actionBadgeBrickRoadStatus)
(lastVisibleActionBadge?.actionBadge !== visibleActionBadge?.actionBadge ||
lastVisibleActionBadge?.actionBadgeBrickRoadStatus !== visibleActionBadge?.actionBadgeBrickRoadStatus ||
lastVisibleActionBadge?.isActionBadgeTargetBelow !== visibleActionBadge?.isActionBadgeTargetBelow)
) {
setLastVisibleActionBadge(visibleActionBadge);
}
Expand Down Expand Up @@ -161,7 +167,7 @@ function FloatingMessageCounter({
<FloatingPillButton
variant={isError ? CONST.BUTTON_VARIANT.DANGER : CONST.BUTTON_VARIANT.SUCCESS}
onPress={onActionBadgePress}
icon={icons.UpArrow}
icon={renderedActionBadge.isActionBadgeTargetBelow ? icons.DownArrow : icons.UpArrow}
iconFill={theme.textLight}
label={actionBadgeText}
textStyle={styles.textWhite}
Expand Down
8 changes: 6 additions & 2 deletions src/pages/inbox/report/ReportActionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct
onViewableItemsChanged,
isFloatingMessageCounterVisible,
isActionBadgeAboveViewport,
isActionBadgeBelowViewport,
scrollToBottomAndMarkReportAsRead,
scrollToActionBadgeTarget,
flushPendingScrollToBottom,
Expand Down Expand Up @@ -366,6 +367,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct
setTreatAsNoPaginationAnchor,
});

const shouldShowActionBadgePill = !isProduction && (isActionBadgeAboveViewport || isActionBadgeBelowViewport);

const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
trackVerticalScrolling(event);
setHasScrolledOverThreshold(event.nativeEvent.contentOffset.y >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD);
Expand Down Expand Up @@ -553,8 +556,9 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct
hasNewMessages={!!unreadMarkerReportActionID}
isActive={isFloatingMessageCounterVisible}
onClick={scrollToBottomAndMarkReportAsRead}
actionBadge={!isProduction && isActionBadgeAboveViewport ? reportAttributes?.actionBadge : undefined}
actionBadgeBrickRoadStatus={!isProduction && isActionBadgeAboveViewport ? reportAttributes?.brickRoadStatus : undefined}
actionBadge={shouldShowActionBadgePill ? reportAttributes?.actionBadge : undefined}
actionBadgeBrickRoadStatus={shouldShowActionBadgePill ? reportAttributes?.brickRoadStatus : undefined}
isActionBadgeTargetBelow={isActionBadgeBelowViewport}
onActionBadgePress={scrollToActionBadgeTarget}
shouldShowMarkAsDoneCopy={shouldShowMarkAsDoneCopy}
/>
Expand Down
15 changes: 11 additions & 4 deletions src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,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 @@ -168,17 +169,21 @@ 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 out of view above or below the viewport
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)
// FlashList reports a snapshot taken up to 250ms earlier, which can predate the new row's measured layout, so prefer the live range
const visibleRange = ref.current.getVisibleIndexRange?.();
const isAbove = isInverted ? badgeTargetIndex > (visibleRange?.endIndex ?? maxIndex) : badgeTargetIndex < (visibleRange?.startIndex ?? minIndex);
const startIndex = visibleRange?.startIndex ?? minIndex;
const endIndex = visibleRange?.endIndex ?? maxIndex;
const isAbove = isInverted ? badgeTargetIndex > endIndex : badgeTargetIndex < startIndex;
const isBelow = isInverted ? badgeTargetIndex < startIndex : badgeTargetIndex > endIndex;
setIsActionBadgeAboveViewport(isAbove);
setIsActionBadgeBelowViewport(isBelow);
} else {
setIsActionBadgeAboveViewport(false);
setIsActionBadgeBelowViewport(false);
}
};

Expand All @@ -203,6 +208,8 @@ export default function useReportUnreadMessageScrollTracking({
setIsFloatingMessageCounterVisible,
isActionBadgeAboveViewport,
setIsActionBadgeAboveViewport,
isActionBadgeBelowViewport,
setIsActionBadgeBelowViewport,
trackVerticalScrolling,
onViewableItemsChanged,
updatePillVisibility,
Expand Down
89 changes: 87 additions & 2 deletions tests/unit/FloatingMessageCounterTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,26 @@ jest.mock('@hooks/useLocalize', () =>
})),
);

// The arrow icons render inside an accessibility-hidden wrapper, so record which one rendered to assert the pill direction
const mockRenderedArrows: string[] = [];
jest.mock('@hooks/useLazyAsset', () => ({
useMemoizedLazyExpensifyIcons: () => ({
DownArrow: () => null,
UpArrow: () => null,
DownArrow: () => {
mockRenderedArrows.push('DownArrow');
return null;
},
UpArrow: () => {
mockRenderedArrows.push('UpArrow');
return null;
},
}),
}));

describe('FloatingMessageCounter', () => {
beforeEach(() => {
mockRenderedArrows.length = 0;
});

it('renders the new messages pill when hasNewMessages is true and no action badge', () => {
render(
<FloatingMessageCounter
Expand Down Expand Up @@ -137,6 +149,79 @@ describe('FloatingMessageCounter', () => {
expect(screen.getByText('Submit')).toBeTruthy();
});

it('points the action badge pill up when the target is above the viewport', () => {
// Given an action badge whose target is above the viewport
Comment on lines +152 to +153

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the missing When phase to this test

For this new test, the render is followed directly by // Then, so there is no // When phase. The repository's delegated testing guidelines require every case to document // Given, // When, and // Then; add a // When comment before render (or otherwise distinguish setup from the action) so this case complies.

AGENTS.md reference: AGENTS.md:L3-L3

Useful? React with 👍 / 👎.

render(
<FloatingMessageCounter
hasNewMessages={false}
onClick={jest.fn()}
actionBadge={CONST.REPORT.ACTION_BADGE.APPROVE}
actionBadgeBrickRoadStatus={CONST.BRICK_ROAD_INDICATOR_STATUS.INFO}
onActionBadgePress={jest.fn()}
/>,
);

// Then the pill points up, because the user has to scroll up to reach the target
expect(screen.getByText('Approve')).toBeTruthy();
expect(mockRenderedArrows).toContain('UpArrow');
expect(mockRenderedArrows).not.toContain('DownArrow');
});

it('points the action badge pill down and scrolls to the target when the target is below the viewport', () => {
// Given an action badge whose target is below the viewport while the user is scrolled up past the threshold
const onActionBadgePressMock = jest.fn();
render(
<FloatingMessageCounter
isActive
hasNewMessages={false}
onClick={jest.fn()}
actionBadge={CONST.REPORT.ACTION_BADGE.APPROVE}
actionBadgeBrickRoadStatus={CONST.BRICK_ROAD_INDICATOR_STATUS.INFO}
onActionBadgePress={onActionBadgePressMock}
isActionBadgeTargetBelow
/>,
);

// When the pill is pressed
fireEvent.press(screen.getByText('Approve'));

// Then the pill points down toward the target and scrolls to it instead of to the latest messages
expect(mockRenderedArrows).toContain('DownArrow');
expect(mockRenderedArrows).not.toContain('UpArrow');
expect(screen.queryByText('Latest messages')).toBeNull();
expect(onActionBadgePressMock).toHaveBeenCalledTimes(1);
});

it('keeps the down arrow while the action badge pill slides out', () => {
// Given the action badge pill points down at a target below the viewport
const {rerender} = render(
<FloatingMessageCounter
hasNewMessages={false}
onClick={jest.fn()}
actionBadge={CONST.REPORT.ACTION_BADGE.APPROVE}
actionBadgeBrickRoadStatus={CONST.BRICK_ROAD_INDICATOR_STATUS.INFO}
onActionBadgePress={jest.fn()}
isActionBadgeTargetBelow
/>,
);
mockRenderedArrows.length = 0;

// When the target scrolls into view, so the list stops passing the badge and the below flag clears
rerender(
<FloatingMessageCounter
hasNewMessages={false}
onClick={jest.fn()}
onActionBadgePress={jest.fn()}
isActionBadgeTargetBelow={false}
/>,
);

// Then the exiting pill still points down, because flipping the arrow during the exit animation would flash an up arrow
expect(screen.getByText('Approve')).toBeTruthy();
expect(mockRenderedArrows).toContain('DownArrow');
expect(mockRenderedArrows).not.toContain('UpArrow');
});

it('does not show action badge pill when only actionBadge is provided without actionBadgeBrickRoadStatus', () => {
render(
<FloatingMessageCounter
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/useReportActionsScrollTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ jest.mock('@hooks/useReportScrollManager', () => ({
// --- useReportUnreadMessageScrollTracking ---
const mockSetIsFloatingMessageCounterVisible = jest.fn();
const mockSetIsActionBadgeAboveViewport = jest.fn();
const mockSetIsActionBadgeBelowViewport = jest.fn();
const mockTrackVerticalScrolling = jest.fn();
const mockOnViewableItemsChanged = jest.fn();
let mockIsFloatingMessageCounterVisible = false;
let mockIsActionBadgeAboveViewport = false;
let mockIsActionBadgeBelowViewport = false;
let mockShouldDisablePillTracking: boolean | undefined;
jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({
__esModule: true,
Expand All @@ -60,6 +62,8 @@ jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({
setIsFloatingMessageCounterVisible: mockSetIsFloatingMessageCounterVisible,
isActionBadgeAboveViewport: mockIsActionBadgeAboveViewport,
setIsActionBadgeAboveViewport: mockSetIsActionBadgeAboveViewport,
isActionBadgeBelowViewport: mockIsActionBadgeBelowViewport,
setIsActionBadgeBelowViewport: mockSetIsActionBadgeBelowViewport,
trackVerticalScrolling: mockTrackVerticalScrolling,
onViewableItemsChanged: mockOnViewableItemsChanged,
};
Expand Down Expand Up @@ -245,6 +249,7 @@ describe('useReportActionsScroll', () => {
mockReportRHPActiveRoute = undefined;
mockIsFloatingMessageCounterVisible = false;
mockIsActionBadgeAboveViewport = false;
mockIsActionBadgeBelowViewport = false;
mockShouldDisablePillTracking = undefined;
mockIsScrollToBottomEnabled = false;
mockIsTransactionThread = false;
Expand Down Expand Up @@ -424,6 +429,7 @@ describe('useReportActionsScroll', () => {

// Then the pills are not hidden directly because the target scrolls into view and viewability tracking hides the pill
expect(mockSetIsActionBadgeAboveViewport).not.toHaveBeenCalled();
expect(mockSetIsActionBadgeBelowViewport).not.toHaveBeenCalled();
expect(mockSetIsFloatingMessageCounterVisible).not.toHaveBeenCalled();
});

Expand All @@ -439,6 +445,7 @@ describe('useReportActionsScroll', () => {

// Then both pills are hidden directly because the target can never become viewable to hide them, and the list still scrolls
expect(mockSetIsActionBadgeAboveViewport).toHaveBeenCalledWith(false);
expect(mockSetIsActionBadgeBelowViewport).toHaveBeenCalledWith(false);
expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false);
expect(mockScrollToIndex).toHaveBeenCalledWith(5, {animated: true, viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET});
});
Expand Down Expand Up @@ -723,5 +730,17 @@ describe('useReportActionsScroll', () => {
result.current.onViewableItemsChanged({viewableItems: [], changed: []});
expect(mockOnViewableItemsChanged).toHaveBeenCalled();
});

it('forwards isActionBadgeBelowViewport from the tracking hook', async () => {
// Given the tracking hook reports the action badge target below the viewport
mockIsActionBadgeBelowViewport = true;

// When the scroll hook renders
const {result} = await renderScroll();

// Then the below flag reaches the list so the pill can point down
expect(result.current.isActionBadgeBelowViewport).toBe(true);
expect(result.current.isActionBadgeAboveViewport).toBe(false);
});
});
});
Loading
Loading