1
1
import type { ListRenderItemInfo } from '@react-native/virtualized-lists/Lists/VirtualizedList' ;
2
2
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' ;
5
4
import type { NativeScrollEvent , NativeSyntheticEvent } from 'react-native' ;
5
+ import { DeviceEventEmitter , InteractionManager , View } from 'react-native' ;
6
6
import type { OnyxCollection , OnyxEntry } from 'react-native-onyx' ;
7
7
import { useOnyx } from 'react-native-onyx' ;
8
8
import FlatList from '@components/FlatList' ;
@@ -13,6 +13,8 @@ import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus';
13
13
import usePrevious from '@hooks/usePrevious' ;
14
14
import useReportScrollManager from '@hooks/useReportScrollManager' ;
15
15
import useThemeStyles from '@hooks/useThemeStyles' ;
16
+ import DateUtils from '@libs/DateUtils' ;
17
+ import { parseFSAttributes } from '@libs/Fullstory' ;
16
18
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID' ;
17
19
import { isActionVisibleOnMoneyRequestReport } from '@libs/MoneyRequestReportUtils' ;
18
20
import {
@@ -24,8 +26,6 @@ import {
24
26
isConsecutiveChronosAutomaticTimerAction ,
25
27
isDeletedParentAction ,
26
28
isReportActionUnread ,
27
- isReportPreviewAction ,
28
- shouldHideNewMarker ,
29
29
shouldReportActionBeVisible ,
30
30
wasMessageReceivedWhileOffline ,
31
31
} from '@libs/ReportActionsUtils' ;
@@ -34,7 +34,8 @@ import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostF
34
34
import Navigation from '@navigation/Navigation' ;
35
35
import FloatingMessageCounter from '@pages/home/report/FloatingMessageCounter' ;
36
36
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' ;
38
39
import CONST from '@src/CONST' ;
39
40
import ONYXKEYS from '@src/ONYXKEYS' ;
40
41
import ROUTES from '@src/ROUTES' ;
@@ -49,6 +50,8 @@ import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyS
49
50
const EmptyParentReportActionForTransactionThread = undefined ;
50
51
51
52
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 ;
52
55
53
56
type MoneyRequestReportListProps = {
54
57
/** The report */
@@ -81,15 +84,12 @@ function selectTransactionsForReportID(transactions: OnyxCollection<OnyxTypes.Tr
81
84
} ) ;
82
85
}
83
86
84
- /**
85
- * TODO make this component have the same functionalities as `ReportActionsList`
86
- * - shouldDisplayNewMarker
87
- */
88
87
function MoneyRequestReportActionsList ( { report, reportActions = [ ] , hasNewerActions, hasOlderActions} : MoneyRequestReportListProps ) {
89
88
const styles = useThemeStyles ( ) ;
90
89
const { translate} = useLocalize ( ) ;
91
90
const { preferredLocale} = useLocalize ( ) ;
92
91
const { isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus ( ) ;
92
+ const reportScrollManager = useReportScrollManager ( ) ;
93
93
94
94
const reportID = report ?. reportID ;
95
95
@@ -110,8 +110,6 @@ function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActi
110
110
const canPerformWriteAction = canUserPerformWriteAction ( report ) ;
111
111
const [ isFloatingMessageCounterVisible , setIsFloatingMessageCounterVisible ] = useState ( false ) ;
112
112
113
- const reportScrollManager = useReportScrollManager ( ) ;
114
-
115
113
// We are reversing actions because in this View we are starting at the top and don't use Inverted list
116
114
const visibleReportActions = useMemo ( ( ) => {
117
115
const filteredActions = reportActions . filter ( ( reportAction ) => {
@@ -127,6 +125,18 @@ function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActi
127
125
return filteredActions . toReversed ( ) ;
128
126
} , [ reportActions , isOffline , canPerformWriteAction ] ) ;
129
127
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
+
130
140
const reportActionIDs = useMemo ( ( ) => {
131
141
return reportActions ?. map ( ( action ) => action . reportActionID ) ?? [ ] ;
132
142
} , [ reportActions ] ) ;
@@ -142,27 +152,16 @@ function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActi
142
152
143
153
const onStartReached = useCallback ( ( ) => {
144
154
if ( ! isSearchTopmostFullScreenRoute ( ) ) {
145
- loadNewerChats ( false ) ;
155
+ loadOlderChats ( false ) ;
146
156
return ;
147
157
}
148
158
149
- InteractionManager . runAfterInteractions ( ( ) => requestAnimationFrame ( ( ) => loadNewerChats ( false ) ) ) ;
150
- } , [ loadNewerChats ] ) ;
151
-
152
- const onEndReached = useCallback ( ( ) => {
153
- loadOlderChats ( false ) ;
159
+ InteractionManager . runAfterInteractions ( ( ) => requestAnimationFrame ( ( ) => loadOlderChats ( false ) ) ) ;
154
160
} , [ loadOlderChats ] ) ;
155
161
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 ] ) ;
166
165
167
166
useEffect ( ( ) => {
168
167
if (
@@ -218,72 +217,95 @@ function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActi
218
217
} , [ isOffline , lastOfflineAt , lastOnlineAt , preferredLocale , reportActions ] ) ;
219
218
220
219
/**
221
- * TODO extract as reusable logic from ReportActionsList - https://github.com/Expensify/App/issues/58891
220
+ * The reportActionID the unread marker should display above
222
221
*/
223
222
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 ;
227
227
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 ) ;
229
233
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
- }
238
234
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
+ } ) ;
240
247
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 ;
244
251
}
252
+ }
245
253
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
+ } ) ;
266
269
267
- return ! isNewMessage || scrollingVerticalOffset . current >= CONST . REPORT . ACTIONS . ACTION_VISIBLE_THRESHOLD ;
270
+ return ( ) => {
271
+ unreadActionSubscription . remove ( ) ;
272
+ readNewestActionSubscription . remove ( ) ;
268
273
} ;
274
+ } , [ report . reportID ] ) ;
269
275
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
+ ) ;
273
294
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 ) ;
277
299
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 ;
281
303
}
282
- }
304
+ unsubscribe ( ) ;
305
+ } ;
283
306
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 ] ) ;
287
309
288
310
const renderItem = useCallback (
289
311
( { item : reportAction , index} : ListRenderItemInfo < OnyxTypes . ReportAction > ) => {
@@ -351,6 +373,9 @@ function MoneyRequestReportActionsList({report, reportActions = [], hasNewerActi
351
373
handleUnreadFloatingButton ( ) ;
352
374
} ;
353
375
376
+ // Parse Fullstory attributes on initial render
377
+ useLayoutEffect ( parseFSAttributes , [ ] ) ;
378
+
354
379
return (
355
380
< View style = { [ styles . flex1 ] } >
356
381
< View style = { [ styles . flex1 , styles . justifyContentEnd , styles . overflowHidden , styles . pb4 ] } >
0 commit comments