From 6f125975be696b350f5ba2478d02cd8bb04a360b Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Wed, 14 May 2025 09:48:19 +0200 Subject: [PATCH] Extract parts of unread message logic to a separate hook. Deduplicates parts of logic that are common between standard ReportActionsList and newer MoneyRequestReportActionsList. --- .../MoneyRequestReportActionsList.tsx | 86 ++++------ src/pages/home/report/ReportActionsList.tsx | 45 ++--- .../useReportUnreadMessageScrollTracking.ts | 67 ++++++++ ...seReportUnreadMessageScrollTrackingTest.ts | 162 ++++++++++++++++++ 4 files changed, 283 insertions(+), 77 deletions(-) create mode 100644 src/pages/home/report/useReportUnreadMessageScrollTracking.ts create mode 100644 tests/unit/useReportUnreadMessageScrollTrackingTest.ts diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 67326dec9d01..cd2846451d8a 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -44,6 +44,7 @@ import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostF import FloatingMessageCounter from '@pages/home/report/FloatingMessageCounter'; import ReportActionsListItemRenderer from '@pages/home/report/ReportActionsListItemRenderer'; import shouldDisplayNewMarkerOnReportAction from '@pages/home/report/shouldDisplayNewMarkerOnReportAction'; +import useReportUnreadMessageScrollTracking from '@pages/home/report/useReportUnreadMessageScrollTracking'; import {openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -114,7 +115,6 @@ function MoneyRequestReportActionsList({report, policy, reportActions = [], tran const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false, selector: (session) => session?.accountID}); const canPerformWriteAction = canUserPerformWriteAction(report); - const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(false); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -184,22 +184,6 @@ function MoneyRequestReportActionsList({report, policy, reportActions = [], tran loadNewerChats(false); }, [loadNewerChats]); - useEffect(() => { - if ( - scrollingVerticalBottomOffset.current < AUTOSCROLL_TO_TOP_THRESHOLD && - previousLastIndex.current !== lastActionIndex && - reportActionSize.current > reportActions.length && - hasNewestReportAction - ) { - setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToEnd(); - } - - previousLastIndex.current = lastActionIndex; - reportActionSize.current = visibleReportActions.length; - hasNewestReportActionRef.current = hasNewestReportAction; - }, [lastActionIndex, reportActions, reportScrollManager, hasNewestReportAction, visibleReportActions.length]); - const prevUnreadMarkerReportActionID = useRef(null); const visibleActionsMap = useMemo(() => { @@ -277,6 +261,40 @@ function MoneyRequestReportActionsList({report, policy, reportActions = [], tran }, [currentUserAccountID, earliestReceivedOfflineMessageIndex, prevVisibleActionsMap, visibleReportActions, unreadMarkerTime]); prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; + const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling} = useReportUnreadMessageScrollTracking({ + reportID: report.reportID, + currentVerticalScrollingOffsetRef: scrollingVerticalBottomOffset, + floatingMessageVisibleInitialValue: false, + readActionSkippedRef: readActionSkipped, + hasUnreadMarkerReportAction: !!unreadMarkerReportActionID, + onTrackScrolling: (event: NativeSyntheticEvent) => { + const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; + const fullContentHeight = contentSize.height; + + /** + * Count the diff between current scroll position and the bottom of the list. + * Diff == (height of all items in the list) - (height of the layout with the list) - (how far user scrolled) + */ + scrollingVerticalBottomOffset.current = fullContentHeight - layoutMeasurement.height - contentOffset.y; + }, + }); + + useEffect(() => { + if ( + scrollingVerticalBottomOffset.current < AUTOSCROLL_TO_TOP_THRESHOLD && + previousLastIndex.current !== lastActionIndex && + reportActionSize.current > reportActions.length && + hasNewestReportAction + ) { + setIsFloatingMessageCounterVisible(false); + reportScrollManager.scrollToEnd(); + } + + previousLastIndex.current = lastActionIndex; + reportActionSize.current = visibleReportActions.length; + hasNewestReportActionRef.current = hasNewestReportAction; + }, [lastActionIndex, reportActions, reportScrollManager, hasNewestReportAction, visibleReportActions.length, setIsFloatingMessageCounterVisible]); + /** * Subscribe to read/unread events and update our unreadMarkerTime */ @@ -330,7 +348,7 @@ function MoneyRequestReportActionsList({report, policy, reportActions = [], tran }, DELAY_FOR_SCROLLING_TO_END); }); }, - [reportScrollManager], + [reportScrollManager, setIsFloatingMessageCounterVisible], ); useEffect(() => { @@ -399,37 +417,7 @@ function MoneyRequestReportActionsList({report, policy, reportActions = [], tran reportScrollManager.scrollToEnd(); readActionSkipped.current = false; readNewestAction(report.reportID); - }, [report.reportID, reportScrollManager, hasNewestReportAction]); - - /** - * Todo - extract to reusable logic - https://github.com/Expensify/App/issues/58891 - * Show/hide the new floating message counter when user is scrolling back/forth in the history of messages. - */ - const handleUnreadFloatingButton = () => { - if (scrollingVerticalBottomOffset.current > CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && !isFloatingMessageCounterVisible && !!unreadMarkerReportActionID) { - setIsFloatingMessageCounterVisible(true); - } - - if (scrollingVerticalBottomOffset.current < CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible) { - if (readActionSkipped.current) { - readActionSkipped.current = false; - readNewestAction(report.reportID); - } - setIsFloatingMessageCounterVisible(false); - } - }; - - const trackVerticalScrolling = (event: NativeSyntheticEvent) => { - const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; - const fullContentHeight = contentSize.height; - - /** - * Count the diff between current scroll position and the bottom of the list. - * Diff == (height of all items in the list) - (height of the layout with the list) - (how far user scrolled) - */ - scrollingVerticalBottomOffset.current = fullContentHeight - layoutMeasurement.height - contentOffset.y; - handleUnreadFloatingButton(); - }; + }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, report.reportID]); const reportHasComments = visibleReportActions.length > 0; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 5eb0306a3b7c..228dfa4356eb 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -62,6 +62,7 @@ import getInitialNumToRender from './getInitialNumReportActionsToRender'; import ListBoundaryLoader from './ListBoundaryLoader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import shouldDisplayNewMarkerOnReportAction from './shouldDisplayNewMarkerOnReportAction'; +import useReportUnreadMessageScrollTracking from './useReportUnreadMessageScrollTracking'; type ReportActionsListProps = { /** The report currently being looked at */ @@ -316,14 +317,25 @@ function ReportActionsList({ const reportActionID = route?.params?.reportActionID; const indexOfLinkedAction = reportActionID ? sortedVisibleReportActions.findIndex((action) => action.reportActionID === reportActionID) : -1; const isLinkedActionCloseToNewest = indexOfLinkedAction < IS_CLOSE_TO_NEWEST_THRESHOLD; - const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(!isLinkedActionCloseToNewest); + + const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling} = useReportUnreadMessageScrollTracking({ + reportID: report.reportID, + currentVerticalScrollingOffsetRef: scrollingVerticalOffset, + floatingMessageVisibleInitialValue: !isLinkedActionCloseToNewest, + readActionSkippedRef: readActionSkipped, + hasUnreadMarkerReportAction: !!unreadMarkerReportActionID, + onTrackScrolling: (event: NativeSyntheticEvent) => { + scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; + onScroll?.(event); + }, + }); useEffect(() => { if (isLinkedActionCloseToNewest) { return; } setIsFloatingMessageCounterVisible(true); - }, [isLinkedActionCloseToNewest, route]); + }, [isLinkedActionCloseToNewest, route, setIsFloatingMessageCounterVisible]); useEffect(() => { if ( @@ -337,7 +349,7 @@ function ReportActionsList({ } previousLastIndex.current = lastActionIndex; reportActionSize.current = sortedVisibleReportActions.length; - }, [lastActionIndex, sortedVisibleReportActions, reportScrollManager, hasNewestReportAction, linkedReportActionID]); + }, [lastActionIndex, sortedVisibleReportActions, reportScrollManager, hasNewestReportAction, linkedReportActionID, setIsFloatingMessageCounterVisible]); useEffect(() => { userActiveSince.current = DateUtils.getDBTime(); @@ -414,7 +426,7 @@ function ReportActionsList({ setIsScrollToBottomEnabled(true); }); }, - [report.reportID, reportScrollManager], + [report.reportID, reportScrollManager, setIsFloatingMessageCounterVisible], ); useEffect(() => { // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? @@ -463,29 +475,6 @@ function ReportActionsList({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [lastAction]); - /** - * Show/hide the new floating message counter when user is scrolling back/forth in the history of messages. - */ - const handleUnreadFloatingButton = () => { - if (scrollingVerticalOffset.current > CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && !isFloatingMessageCounterVisible && !!unreadMarkerReportActionID) { - setIsFloatingMessageCounterVisible(true); - } - - if (scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible) { - if (readActionSkipped.current) { - readActionSkipped.current = false; - readNewestAction(report.reportID); - } - setIsFloatingMessageCounterVisible(false); - } - }; - - const trackVerticalScrolling = (event: NativeSyntheticEvent) => { - scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; - handleUnreadFloatingButton(); - onScroll?.(event); - }; - const scrollToBottomAndMarkReportAsRead = useCallback(() => { setIsFloatingMessageCounterVisible(false); @@ -498,7 +487,7 @@ function ReportActionsList({ reportScrollManager.scrollToBottom(); readActionSkipped.current = false; readNewestAction(report.reportID); - }, [report.reportID, reportScrollManager, hasNewestReportAction]); + }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, report.reportID]); /** * Calculates the ideal number of report actions to render in the first render, based on the screen height and on diff --git a/src/pages/home/report/useReportUnreadMessageScrollTracking.ts b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts new file mode 100644 index 000000000000..76cd1ea43f7f --- /dev/null +++ b/src/pages/home/report/useReportUnreadMessageScrollTracking.ts @@ -0,0 +1,67 @@ +import {useState} from 'react'; +import type {MutableRefObject} from 'react'; +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import {readNewestAction} from '@userActions/Report'; +import CONST from '@src/CONST'; + +type Args = { + /** The report ID */ + reportID: string; + + /** The current offset of scrolling from either top or bottom of chat list */ + currentVerticalScrollingOffsetRef: MutableRefObject; + + /** Ref for whether read action was skipped */ + readActionSkippedRef: MutableRefObject; + + /** The initial value for visibility of floating message button */ + floatingMessageVisibleInitialValue: boolean; + + /** Whether the unread marker is displayed for any report action */ + hasUnreadMarkerReportAction: boolean; + + /** Callback to call on every scroll event */ + onTrackScrolling: (event: NativeSyntheticEvent) => void; +}; + +export default function useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef, + floatingMessageVisibleInitialValue, + hasUnreadMarkerReportAction, + readActionSkippedRef, + onTrackScrolling, +}: Args) { + const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(floatingMessageVisibleInitialValue); + + /** + * On every scroll event we want to: + * Show/hide the new floating message counter when user is scrolling back/forth in the history of messages. + * Call any other callback that the component might need + */ + const trackVerticalScrolling = (event: NativeSyntheticEvent) => { + onTrackScrolling(event); + + // display floating button if we're scrolled more than the offset + if (currentVerticalScrollingOffsetRef.current > CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && !isFloatingMessageCounterVisible && hasUnreadMarkerReportAction) { + setIsFloatingMessageCounterVisible(true); + } + + // hide floating button if we're scrolled closer than the offset and mark message as read + if (currentVerticalScrollingOffsetRef.current < CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible) { + if (readActionSkippedRef.current) { + // eslint-disable-next-line react-compiler/react-compiler,no-param-reassign + readActionSkippedRef.current = false; + readNewestAction(reportID); + } + + setIsFloatingMessageCounterVisible(false); + } + }; + + return { + isFloatingMessageCounterVisible, + setIsFloatingMessageCounterVisible, + trackVerticalScrolling, + }; +} diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts new file mode 100644 index 000000000000..62f7ebda0b4c --- /dev/null +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -0,0 +1,162 @@ +import {act, renderHook} from '@testing-library/react-native'; +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import useReportUnreadMessageScrollTracking from '@pages/home/report/useReportUnreadMessageScrollTracking'; +import {readNewestAction} from '@userActions/Report'; +import CONST from '@src/CONST'; + +jest.mock('@userActions/Report', () => { + return { + readNewestAction: jest.fn(), + }; +}); + +const reportID = '12345'; +const readActionRefFalse = {current: false}; +const emptyScrollEventMock = { + nativeEvent: {layoutMeasurement: {height: 0, width: 0}, contentSize: {width: 100, height: 100}, contentOffset: {x: 0, y: 0}}, +} as NativeSyntheticEvent; + +describe('useReportUnreadMessageScrollTracking', () => { + describe('on init and without any scrolling', () => { + const onTrackScrollingMockFn = jest.fn(); + + it('returns initial floatingMessage visibility and sets no state', () => { + // Given + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + readActionSkippedRef: readActionRefFalse, + floatingMessageVisibleInitialValue: false, + hasUnreadMarkerReportAction: false, + onTrackScrolling: onTrackScrollingMockFn, + }), + ); + + // Then + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + expect(onTrackScrollingMockFn).not.toBeCalled(); + }); + + it('returns floatingMessage visibility that was set to a new value', () => { + // Given + const offsetRef = {current: 0}; + const {result, rerender} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + readActionSkippedRef: readActionRefFalse, + floatingMessageVisibleInitialValue: false, + hasUnreadMarkerReportAction: false, + onTrackScrolling: onTrackScrollingMockFn, + }), + ); + + // When + act(() => { + result.current.setIsFloatingMessageCounterVisible(true); + }); + rerender({}); + + // Then + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + expect(onTrackScrollingMockFn).not.toBeCalled(); + }); + }); + + describe('when scrolling', () => { + const onTrackScrollingMockFn = jest.fn(); + + it('returns floatingMessage visibility as true when scrolling outside of threshold', () => { + // Given + const offsetRef = {current: 0}; + const {result, rerender} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + readActionSkippedRef: readActionRefFalse, + floatingMessageVisibleInitialValue: false, + hasUnreadMarkerReportAction: true, + onTrackScrolling: onTrackScrollingMockFn, + }), + ); + + // When + act(() => { + offsetRef.current = CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD + 100; + result.current.trackVerticalScrolling(emptyScrollEventMock); + }); + rerender({}); + + // Then + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + expect(onTrackScrollingMockFn).toBeCalledWith(emptyScrollEventMock); + }); + + it('returns floatingMessage visibility as false when scrolling inside the threshold', () => { + // Given + const offsetRef = {current: 0}; + const {result, rerender} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + readActionSkippedRef: readActionRefFalse, + floatingMessageVisibleInitialValue: false, + hasUnreadMarkerReportAction: true, + onTrackScrolling: onTrackScrollingMockFn, + }), + ); + + // When + act(() => { + offsetRef.current = CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD - 100; + result.current.trackVerticalScrolling(emptyScrollEventMock); + }); + rerender({}); + + // Then + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + expect(onTrackScrollingMockFn).toBeCalledWith(emptyScrollEventMock); + }); + + it('calls readAction when scrolling inside the threshold and the message and read action skipped is true', () => { + // Given + const offsetRef = {current: 0}; + const {result, rerender} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + readActionSkippedRef: {current: true}, + floatingMessageVisibleInitialValue: false, + hasUnreadMarkerReportAction: true, + onTrackScrolling: onTrackScrollingMockFn, + }), + ); + + // When + act(() => { + // offset greater, will set visible to true + offsetRef.current = CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD + 100; + result.current.trackVerticalScrolling(emptyScrollEventMock); + }); + + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + expect(readNewestAction).toBeCalledTimes(0); + + rerender({}); + + act(() => { + // scrolling into the offset, should call readNewestAction + offsetRef.current = CONST.REPORT.ACTIONS.SCROLL_VERTICAL_OFFSET_THRESHOLD - 100; + result.current.trackVerticalScrolling(emptyScrollEventMock); + }); + + // Then + expect(readNewestAction).toBeCalledTimes(1); + expect(onTrackScrollingMockFn).toBeCalledWith(emptyScrollEventMock); + expect(readActionRefFalse.current).toBe(false); + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + }); + }); +});