Skip to content

Commit 319e605

Browse files
committed
Fix scrolling to bottom list on MoneyRequestReportView
1 parent a08e8f7 commit 319e605

File tree

6 files changed

+248
-157
lines changed

6 files changed

+248
-157
lines changed

src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx

+103-78
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type {ListRenderItemInfo} from '@react-native/virtualized-lists/Lists/VirtualizedList';
22
import isEmpty from 'lodash/isEmpty';
3-
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
4-
import {InteractionManager, View} from 'react-native';
3+
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
54
import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
5+
import {DeviceEventEmitter, InteractionManager, View} from 'react-native';
66
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
77
import {useOnyx} from 'react-native-onyx';
88
import FlatList from '@components/FlatList';
@@ -13,6 +13,8 @@ import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus';
1313
import usePrevious from '@hooks/usePrevious';
1414
import useReportScrollManager from '@hooks/useReportScrollManager';
1515
import useThemeStyles from '@hooks/useThemeStyles';
16+
import DateUtils from '@libs/DateUtils';
17+
import {parseFSAttributes} from '@libs/Fullstory';
1618
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
1719
import {isActionVisibleOnMoneyRequestReport} from '@libs/MoneyRequestReportUtils';
1820
import {
@@ -24,8 +26,6 @@ import {
2426
isConsecutiveChronosAutomaticTimerAction,
2527
isDeletedParentAction,
2628
isReportActionUnread,
27-
isReportPreviewAction,
28-
shouldHideNewMarker,
2929
shouldReportActionBeVisible,
3030
wasMessageReceivedWhileOffline,
3131
} from '@libs/ReportActionsUtils';
@@ -34,7 +34,8 @@ import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostF
3434
import Navigation from '@navigation/Navigation';
3535
import FloatingMessageCounter from '@pages/home/report/FloatingMessageCounter';
3636
import ReportActionsListItemRenderer from '@pages/home/report/ReportActionsListItemRenderer';
37-
import {openReport, readNewestAction} from '@userActions/Report';
37+
import shouldDisplayNewMarkerOnReportAction from '@pages/home/report/shouldDisplayNewMarkerOnReportAction';
38+
import {openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report';
3839
import CONST from '@src/CONST';
3940
import ONYXKEYS from '@src/ONYXKEYS';
4041
import ROUTES from '@src/ROUTES';
@@ -49,6 +50,8 @@ import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyS
4950
const EmptyParentReportActionForTransactionThread = undefined;
5051

5152
const INITIAL_NUM_TO_RENDER = 20;
53+
// Amount of time to wait until all list items should be rendered and scrollToEnd will behave well
54+
const DELAY_FOR_SCROLLING_TO_END = 100;
5255

5356
type MoneyRequestReportListProps = {
5457
/** The report */
@@ -81,15 +84,12 @@ function selectTransactionsForReportID(transactions: OnyxCollection<OnyxTypes.Tr
8184
});
8285
}
8386

84-
/**
85-
* TODO make this component have the same functionalities as `ReportActionsList`
86-
* - shouldDisplayNewMarker
87-
*/
8887
function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActions, hasOlderActions}: MoneyRequestReportListProps) {
8988
const styles = useThemeStyles();
9089
const {translate} = useLocalize();
9190
const {preferredLocale} = useLocalize();
9291
const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus();
92+
const reportScrollManager = useReportScrollManager();
9393

9494
const reportID = report?.reportID;
9595

@@ -110,8 +110,6 @@ function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActi
110110
const canPerformWriteAction = canUserPerformWriteAction(report);
111111
const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(false);
112112

113-
const reportScrollManager = useReportScrollManager();
114-
115113
// We are reversing actions because in this View we are starting at the top and don't use Inverted list
116114
const visibleReportActions = useMemo(() => {
117115
const filteredActions = reportActions.filter((reportAction) => {
@@ -127,6 +125,18 @@ function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActi
127125
return filteredActions.toReversed();
128126
}, [reportActions, isOffline, canPerformWriteAction]);
129127

128+
const reportActionSize = useRef(visibleReportActions.length);
129+
const lastAction = visibleReportActions.at(-1);
130+
const lastActionIndex = lastAction?.reportActionID;
131+
const previousLastIndex = useRef(lastActionIndex);
132+
133+
const scrollingVerticalOffset = useRef(0);
134+
const readActionSkipped = useRef(false);
135+
const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport);
136+
const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated;
137+
const hasNewestReportActionRef = useRef(hasNewestReportAction);
138+
const userActiveSince = useRef<string>(DateUtils.getDBTime());
139+
130140
const reportActionIDs = useMemo(() => {
131141
return reportActions?.map((action) => action.reportActionID) ?? [];
132142
}, [reportActions]);
@@ -142,27 +152,16 @@ function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActi
142152

143153
const onStartReached = useCallback(() => {
144154
if (!isSearchTopmostFullScreenRoute()) {
145-
loadNewerChats(false);
155+
loadOlderChats(false);
146156
return;
147157
}
148158

149-
InteractionManager.runAfterInteractions(() => requestAnimationFrame(() => loadNewerChats(false)));
150-
}, [loadNewerChats]);
151-
152-
const onEndReached = useCallback(() => {
153-
loadOlderChats(false);
159+
InteractionManager.runAfterInteractions(() => requestAnimationFrame(() => loadOlderChats(false)));
154160
}, [loadOlderChats]);
155161

156-
const reportActionSize = useRef(visibleReportActions.length);
157-
const lastAction = visibleReportActions.at(-1);
158-
const lastActionIndex = lastAction?.reportActionID;
159-
const previousLastIndex = useRef(lastActionIndex);
160-
161-
const scrollingVerticalOffset = useRef(0);
162-
const readActionSkipped = useRef(false);
163-
const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport);
164-
const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated;
165-
const hasNewestReportActionRef = useRef(hasNewestReportAction);
162+
const onEndReached = useCallback(() => {
163+
loadNewerChats(false);
164+
}, [loadNewerChats]);
166165

167166
useEffect(() => {
168167
if (
@@ -218,72 +217,95 @@ function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActi
218217
}, [isOffline, lastOfflineAt, lastOnlineAt, preferredLocale, reportActions]);
219218

220219
/**
221-
* TODO extract as reusable logic from ReportActionsList - https://github.com/Expensify/App/issues/58891
220+
* The reportActionID the unread marker should display above
222221
*/
223222
const unreadMarkerReportActionID = useMemo(() => {
224-
const shouldDisplayNewMarker = (message: OnyxTypes.ReportAction, index: number): boolean => {
225-
const nextMessage = visibleReportActions.at(index + 1);
226-
const isNextMessageUnread = !!nextMessage && isReportActionUnread(nextMessage, unreadMarkerTime);
223+
// If there are message that were received while offline,
224+
// we can skip checking all messages later than the earliest received offline message.
225+
const startIndex = visibleReportActions.length - 1;
226+
const endIndex = earliestReceivedOfflineMessageIndex ?? 0;
227227

228-
// If the current message is the earliest message received while offline, we want to display the unread marker above this message.
228+
// Scan through each visible report action until we find the appropriate action to show the unread marker
229+
for (let index = startIndex; index >= endIndex; index--) {
230+
const reportAction = visibleReportActions.at(index);
231+
const nextAction = visibleReportActions.at(index - 1);
232+
const isNextMessageUnread = !!nextAction && isReportActionUnread(nextAction, unreadMarkerTime);
229233
const isEarliestReceivedOfflineMessage = index === earliestReceivedOfflineMessageIndex;
230-
if (isEarliestReceivedOfflineMessage && !isNextMessageUnread) {
231-
return true;
232-
}
233-
234-
// If the unread marker should be hidden or is not within the visible area, don't show the unread marker.
235-
if (shouldHideNewMarker(message)) {
236-
return false;
237-
}
238234

239-
const isCurrentMessageUnread = isReportActionUnread(message, unreadMarkerTime);
235+
const shouldDisplayNewMarker =
236+
reportAction &&
237+
shouldDisplayNewMarkerOnReportAction({
238+
message: reportAction,
239+
isNextMessageUnread,
240+
isEarliestReceivedOfflineMessage,
241+
accountID: currentUserAccountID,
242+
prevSortedVisibleReportActionsObjects: prevVisibleActionsMap,
243+
unreadMarkerTime,
244+
scrollingVerticalOffset: scrollingVerticalOffset.current,
245+
prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current,
246+
});
240247

241-
// If the current message is read or the next message is unread, don't show the unread marker.
242-
if (!isCurrentMessageUnread || isNextMessageUnread) {
243-
return false;
248+
// eslint-disable-next-line react-compiler/react-compiler
249+
if (shouldDisplayNewMarker) {
250+
return reportAction.reportActionID;
244251
}
252+
}
245253

246-
const isPendingAdd = (action: OnyxTypes.ReportAction) => {
247-
return action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD;
248-
};
249-
250-
// If no unread marker exists, don't set an unread marker for newly added messages from the current user.
251-
const isFromCurrentUser = currentUserAccountID === (isReportPreviewAction(message) ? message.childLastActorAccountID : message.actorAccountID);
252-
const isNewMessage = !prevVisibleActionsMap[message.reportActionID];
253-
254-
// The unread marker will show if the action's `created` time is later than `unreadMarkerTime`.
255-
// The `unreadMarkerTime` has already been updated to match the optimistic action created time,
256-
// but once the new action is saved on the backend, the actual created time will be later than the optimistic one.
257-
// Therefore, we also need to prevent the unread marker from appearing for previously optimistic actions.
258-
const isPreviouslyOptimistic =
259-
(isPendingAdd(prevVisibleActionsMap[message.reportActionID]) && !isPendingAdd(message)) ||
260-
(!!prevVisibleActionsMap[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction);
261-
const shouldIgnoreUnreadForCurrentUserMessage = !prevUnreadMarkerReportActionID.current && isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic);
262-
263-
if (isFromCurrentUser) {
264-
return !shouldIgnoreUnreadForCurrentUserMessage;
265-
}
254+
return null;
255+
}, [currentUserAccountID, earliestReceivedOfflineMessageIndex, prevVisibleActionsMap, visibleReportActions, unreadMarkerTime]);
256+
prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID;
257+
258+
/**
259+
* Subscribe to read/unread events and update our unreadMarkerTime
260+
*/
261+
useEffect(() => {
262+
const unreadActionSubscription = DeviceEventEmitter.addListener(`unreadAction_${report.reportID}`, (newLastReadTime: string) => {
263+
setUnreadMarkerTime(newLastReadTime);
264+
userActiveSince.current = DateUtils.getDBTime();
265+
});
266+
const readNewestActionSubscription = DeviceEventEmitter.addListener(`readNewestAction_${report.reportID}`, (newLastReadTime: string) => {
267+
setUnreadMarkerTime(newLastReadTime);
268+
});
266269

267-
return !isNewMessage || scrollingVerticalOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD;
270+
return () => {
271+
unreadActionSubscription.remove();
272+
readNewestActionSubscription.remove();
268273
};
274+
}, [report.reportID]);
269275

270-
// If there are message that were recevied while offline,
271-
// we can skip checking all messages later than the earliest recevied offline message.
272-
const startIndex = earliestReceivedOfflineMessageIndex ?? 0;
276+
const scrollToBottomForCurrentUserAction = useCallback(
277+
(isFromCurrentUser: boolean) => {
278+
InteractionManager.runAfterInteractions(() => {
279+
setIsFloatingMessageCounterVisible(false);
280+
// If a new comment is added from the current user, scroll to the bottom, otherwise leave the user position unchanged
281+
if (!isFromCurrentUser) {
282+
return;
283+
}
284+
285+
// We want to scroll to the end of the list where the newest message is
286+
// however scrollToEnd will not work correctly with items of variable sizes without `getItemLayout` - so we need to delay the scroll until every item rendered
287+
setTimeout(() => {
288+
reportScrollManager.scrollToEnd();
289+
}, DELAY_FOR_SCROLLING_TO_END);
290+
});
291+
},
292+
[reportScrollManager],
293+
);
273294

274-
// Scan through each visible report action until we find the appropriate action to show the unread marker
275-
for (let index = startIndex; index < visibleReportActions.length; index++) {
276-
const reportAction = visibleReportActions.at(index);
295+
useEffect(() => {
296+
// This callback is triggered when a new action arrives via Pusher and the event is emitted from Report.js. This allows us to maintain
297+
// a single source of truth for the "new action" event instead of trying to derive that a new action has appeared from looking at props.
298+
const unsubscribe = subscribeToNewActionEvent(report.reportID, scrollToBottomForCurrentUserAction);
277299

278-
// eslint-disable-next-line react-compiler/react-compiler
279-
if (reportAction && shouldDisplayNewMarker(reportAction, index)) {
280-
return reportAction.reportActionID;
300+
return () => {
301+
if (!unsubscribe) {
302+
return;
281303
}
282-
}
304+
unsubscribe();
305+
};
283306

284-
return null;
285-
}, [currentUserAccountID, earliestReceivedOfflineMessageIndex, prevVisibleActionsMap, visibleReportActions, unreadMarkerTime]);
286-
prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID;
307+
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
308+
}, [report.reportID]);
287309

288310
const renderItem = useCallback(
289311
({item: reportAction, index}: ListRenderItemInfo<OnyxTypes.ReportAction>) => {
@@ -351,6 +373,9 @@ function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActi
351373
handleUnreadFloatingButton();
352374
};
353375

376+
// Parse Fullstory attributes on initial render
377+
useLayoutEffect(parseFSAttributes, []);
378+
354379
return (
355380
<View style={[styles.flex1]}>
356381
<View style={[styles.flex1, styles.justifyContentEnd, styles.overflowHidden, styles.pb4]}>

src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ function MoneyRequestReportTransactionList({report, transactions, reportActions,
180180
id={transaction.transactionID}
181181
style={[pressableStyle]}
182182
onMouseLeave={handleMouseLeave}
183+
key={transaction.transactionID}
183184
>
184185
<TransactionItemRow
185186
transactionItem={transaction}

src/hooks/useReportScrollManager/index.native.ts

+9
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {useCallback, useContext} from 'react';
2+
// eslint-disable-next-line no-restricted-imports
3+
import type {ScrollView} from 'react-native';
24
import {ActionListContext} from '@pages/home/ReportScreenContext';
35
import type ReportScrollManagerData from './types';
46

@@ -41,6 +43,13 @@ function useReportScrollManager(): ReportScrollManagerData {
4143
return;
4244
}
4345

46+
const scrollViewRef = flatListRef.current.getNativeScrollRef();
47+
// Try to scroll on underlying scrollView if available, fallback to usual listRef
48+
if (scrollViewRef && 'scrollToEnd' in scrollViewRef) {
49+
(scrollViewRef as ScrollView).scrollToEnd({animated: false});
50+
return;
51+
}
52+
4453
flatListRef.current.scrollToEnd({animated: false});
4554
}, [flatListRef]);
4655

src/pages/Search/SearchMoneyRequestReportPage.tsx

+28-24
Original file line numberDiff line numberDiff line change
@@ -84,30 +84,34 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) {
8484

8585
if (shouldUseNarrowLayout) {
8686
return (
87-
<ScreenWrapper
88-
testID={SearchMoneyRequestReportPage.displayName}
89-
shouldEnableMaxHeight
90-
offlineIndicatorStyle={styles.mtAuto}
91-
headerGapStyles={styles.searchHeaderGap}
92-
>
93-
<FullPageNotFoundView
94-
shouldShow={shouldShowNotFoundPage}
95-
subtitleKey="notFound.noAccess"
96-
subtitleStyle={[styles.textSupporting]}
97-
shouldDisplaySearchRouter
98-
shouldShowBackButton={shouldUseNarrowLayout}
99-
onBackButtonPress={Navigation.goBack}
100-
linkKey="notFound.noAccess"
101-
>
102-
<MoneyRequestReportView
103-
report={report}
104-
reportMetadata={reportMetadata}
105-
policy={policy}
106-
shouldDisplayReportFooter={isCurrentReportLoadedFromOnyx}
107-
backToRoute={route.params.backTo}
108-
/>
109-
</FullPageNotFoundView>
110-
</ScreenWrapper>
87+
<ActionListContext.Provider value={actionListValue}>
88+
<ReactionListContext.Provider value={reactionListRef}>
89+
<ScreenWrapper
90+
testID={SearchMoneyRequestReportPage.displayName}
91+
shouldEnableMaxHeight
92+
offlineIndicatorStyle={styles.mtAuto}
93+
headerGapStyles={styles.searchHeaderGap}
94+
>
95+
<FullPageNotFoundView
96+
shouldShow={shouldShowNotFoundPage}
97+
subtitleKey="notFound.noAccess"
98+
subtitleStyle={[styles.textSupporting]}
99+
shouldDisplaySearchRouter
100+
shouldShowBackButton={shouldUseNarrowLayout}
101+
onBackButtonPress={Navigation.goBack}
102+
linkKey="notFound.noAccess"
103+
>
104+
<MoneyRequestReportView
105+
report={report}
106+
reportMetadata={reportMetadata}
107+
policy={policy}
108+
shouldDisplayReportFooter={isCurrentReportLoadedFromOnyx}
109+
backToRoute={route.params.backTo}
110+
/>
111+
</FullPageNotFoundView>
112+
</ScreenWrapper>
113+
</ReactionListContext.Provider>
114+
</ActionListContext.Provider>
111115
);
112116
}
113117

0 commit comments

Comments
 (0)