-
Notifications
You must be signed in to change notification settings - Fork 3k
/
Copy pathReportActionsView.js
341 lines (300 loc) · 12.2 KB
/
ReportActionsView.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
import React from 'react';
import {
View,
Keyboard,
AppState,
ActivityIndicator,
} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
import lodashGet from 'lodash/get';
import {withOnyx} from 'react-native-onyx';
import Text from '../../../components/Text';
import {
fetchActions,
updateLastReadActionID,
subscribeToReportTypingEvents,
unsubscribeFromReportChannel,
} from '../../../libs/actions/Report';
import ONYXKEYS from '../../../ONYXKEYS';
import ReportActionItem from './ReportActionItem';
import styles from '../../../styles/styles';
import ReportActionPropTypes from './ReportActionPropTypes';
import InvertedFlatList from '../../../components/InvertedFlatList';
import {lastItem} from '../../../libs/CollectionUtils';
import Visibility from '../../../libs/Visibility';
import Timing from '../../../libs/actions/Timing';
import CONST from '../../../CONST';
import themeColors from '../../../styles/themes/default';
const propTypes = {
// The ID of the report actions will be created for
reportID: PropTypes.number.isRequired,
/* Onyx Props */
// The report currently being looked at
report: PropTypes.shape({
// Number of actions unread
unreadActionCount: PropTypes.number,
// The largest sequenceNumber on this report
maxSequenceNumber: PropTypes.number,
}),
// Array of report actions for this report
reportActions: PropTypes.objectOf(PropTypes.shape(ReportActionPropTypes)),
// The session of the logged in person
session: PropTypes.shape({
// Email of the logged in person
email: PropTypes.string,
}),
};
const defaultProps = {
report: {
unreadActionCount: 0,
maxSequenceNumber: 0,
},
reportActions: {},
session: {},
};
class ReportActionsView extends React.Component {
constructor(props) {
super(props);
this.renderItem = this.renderItem.bind(this);
this.renderCell = this.renderCell.bind(this);
this.scrollToListBottom = this.scrollToListBottom.bind(this);
this.recordMaxAction = this.recordMaxAction.bind(this);
this.onVisibilityChange = this.onVisibilityChange.bind(this);
this.loadMoreChats = this.loadMoreChats.bind(this);
this.sortedReportActions = [];
this.timers = [];
this.initialNewMarkerPosition = props.report.unreadActionCount === 0
? 0
: (props.report.maxSequenceNumber + 1) - props.report.unreadActionCount;
this.state = {
isLoadingMoreChats: false,
};
this.updateSortedReportActions(props.reportActions);
}
componentDidMount() {
AppState.addEventListener('change', this.onVisibilityChange);
subscribeToReportTypingEvents(this.props.reportID);
this.keyboardEvent = Keyboard.addListener('keyboardDidShow', this.scrollToListBottom);
this.recordMaxAction();
fetchActions(this.props.reportID);
Timing.end(CONST.TIMING.SWITCH_REPORT, CONST.TIMING.COLD);
}
shouldComponentUpdate(nextProps, nextState) {
if (!_.isEqual(nextProps.reportActions, this.props.reportActions)) {
this.updateSortedReportActions(nextProps.reportActions);
return true;
}
if (nextState.isLoadingMoreChats !== this.state.isLoadingMoreChats) {
return true;
}
return false;
}
componentDidUpdate(prevProps) {
// The last sequenceNumber of the same report has changed.
const previousLastSequenceNumber = lodashGet(lastItem(prevProps.reportActions), 'sequenceNumber');
const currentLastSequenceNumber = lodashGet(lastItem(this.props.reportActions), 'sequenceNumber');
if (previousLastSequenceNumber !== currentLastSequenceNumber) {
// If a new comment is added and it's from the current user scroll to the bottom otherwise
// leave the user positioned where they are now in the list.
const lastAction = lastItem(this.props.reportActions);
if (lastAction && (lastAction.actorEmail === this.props.session.email)) {
this.scrollToListBottom();
}
// When the last action changes, wait three seconds, then record the max action
// This will make the unread indicator go away if you receive comments in the same chat you're looking at
if (Visibility.isVisible()) {
this.timers.push(setTimeout(this.recordMaxAction, 3000));
}
}
}
componentWillUnmount() {
if (this.keyboardEvent) {
this.keyboardEvent.remove();
}
AppState.removeEventListener('change', this.onVisibilityChange);
_.each(this.timers, timer => clearTimeout(timer));
unsubscribeFromReportChannel(this.props.reportID);
}
/**
* Records the max action on app visibility change event.
*/
onVisibilityChange() {
if (Visibility.isVisible()) {
this.timers.push(setTimeout(this.recordMaxAction, 3000));
}
}
/**
* Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently
* displaying.
*/
loadMoreChats() {
const minSequenceNumber = _.chain(this.props.reportActions)
.pluck('sequenceNumber')
.min()
.value();
if (minSequenceNumber === 0) {
return;
}
this.setState({isLoadingMoreChats: true}, () => {
// Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments, unless we're near the beginning, in which
// case just get everything starting from 0.
const offset = Math.max(minSequenceNumber - CONST.REPORT.ACTIONS.LIMIT, 0);
fetchActions(this.props.reportID, offset)
.then(() => this.setState({isLoadingMoreChats: false}));
});
}
/**
* Updates and sorts the report actions by sequence number
*
* @param {Array<{sequenceNumber, actionName}>} reportActions
*/
updateSortedReportActions(reportActions) {
this.sortedReportActions = _.chain(reportActions)
.sortBy('sequenceNumber')
.filter(action => action.actionName === 'ADDCOMMENT' || action.actionName === 'IOU')
.map((item, index) => ({action: item, index}))
.value()
.reverse();
}
/**
* Returns true when the report action immediately before the
* specified index is a comment made by the same actor who who
* is leaving a comment in the action at the specified index.
* Also checks to ensure that the comment is not too old to
* be considered part of the same comment
*
* @param {Number} actionIndex - index of the comment item in state to check
*
* @return {Boolean}
*/
isConsecutiveActionMadeByPreviousActor(actionIndex) {
const previousAction = this.sortedReportActions[actionIndex + 1];
const currentAction = this.sortedReportActions[actionIndex];
// It's OK for there to be no previous action, and in that case, false will be returned
// so that the comment isn't grouped
if (!currentAction || !previousAction) {
return false;
}
// Comments are only grouped if they happen within 5 minutes of each other
if (currentAction.action.timestamp - previousAction.action.timestamp > 300) {
return false;
}
return currentAction.action.actorEmail === previousAction.action.actorEmail;
}
/**
* Recorded when the report first opens and when the list is scrolled to the bottom
*/
recordMaxAction() {
const reportActions = lodashGet(this.props, 'reportActions', {});
const maxVisibleSequenceNumber = _.chain(reportActions)
// We want to avoid marking any pending actions as read since
// 1. Any action ID that hasn't been delivered by the server is a temporary action ID.
// 2. We already set a comment someone has authored as the lastReadActionID_<accountID> rNVP on the server
// and should sync it locally when we handle it via Pusher or Airship
.reject(action => action.loading)
.pluck('sequenceNumber')
.max()
.value();
updateLastReadActionID(this.props.reportID, maxVisibleSequenceNumber);
}
/**
* This function is triggered from the ref callback for the scrollview. That way it can be scrolled once all the
* items have been rendered. If the number of actions has changed since it was last rendered, then
* scroll the list to the end. As a report can contain non-message actions, we should confirm that list data exists.
*/
scrollToListBottom() {
if (this.actionListElement) {
this.actionListElement.scrollToIndex({animated: false, index: 0});
}
this.recordMaxAction();
}
/**
* This function overrides the CellRendererComponent (defaults to a plain View), giving each ReportActionItem a
* higher z-index than the one below it. This prevents issues where the ReportActionContextMenu overlapping between
* rows is hidden beneath other rows.
*
* @param {Object} index - The ReportAction item in the FlatList.
* @param {Object|Array} style – The default styles of the CellRendererComponent provided by the CellRenderer.
* @param {Object} props – All the other Props provided to the CellRendererComponent by default.
* @returns {React.Component}
*/
renderCell({item, style, ...props}) {
const cellStyle = [
style,
{zIndex: item.action.sequenceNumber},
];
// eslint-disable-next-line react/jsx-props-no-spreading
return <View style={cellStyle} {...props} />;
}
/**
* Do not move this or make it an anonymous function it is a method
* so it will not be recreated each time we render an item
*
* See: https://reactnative.dev/docs/optimizing-flatlist-configuration#avoid-anonymous-function-on-renderitem
*
* @param {Object} args
* @param {Object} args.item
* @param {Number} args.index
*
* @returns {React.Component}
*/
renderItem({
item,
index,
}) {
return (
<ReportActionItem
reportID={this.props.reportID}
action={item.action}
displayAsGroup={this.isConsecutiveActionMadeByPreviousActor(index)}
shouldDisplayNewIndicator={this.initialNewMarkerPosition > 0
&& item.action.sequenceNumber === this.initialNewMarkerPosition}
/>
);
}
render() {
// Comments have not loaded at all yet do nothing
if (!_.size(this.props.reportActions)) {
return null;
}
// If we only have the created action then no one has left a comment
if (_.size(this.props.reportActions) === 1) {
return (
<View style={[styles.chatContent, styles.chatContentEmpty]}>
<Text style={[styles.textP]}>Be the first person to comment!</Text>
</View>
);
}
return (
<InvertedFlatList
ref={el => this.actionListElement = el}
data={this.sortedReportActions}
renderItem={this.renderItem}
CellRendererComponent={this.renderCell}
contentContainerStyle={[styles.chatContentScrollView]}
keyExtractor={item => `${item.action.sequenceNumber}`}
initialRowHeight={32}
onEndReached={this.loadMoreChats}
onEndReachedThreshold={0.75}
ListFooterComponent={this.state.isLoadingMoreChats
? <ActivityIndicator size="small" color={themeColors.spinner} />
: null}
/>
);
}
}
ReportActionsView.propTypes = propTypes;
ReportActionsView.defaultProps = defaultProps;
export default withOnyx({
report: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
},
reportActions: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
canEvict: false,
},
session: {
key: ONYXKEYS.SESSION,
},
})(ReportActionsView);