diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.js b/src/components/InvertedFlatList/BaseInvertedFlatList.js
new file mode 100644
index 000000000000..e753ff4e9bd9
--- /dev/null
+++ b/src/components/InvertedFlatList/BaseInvertedFlatList.js
@@ -0,0 +1,151 @@
+import _ from 'underscore';
+import React, {forwardRef, Component} from 'react';
+import PropTypes from 'prop-types';
+import {FlatList, View} from 'react-native';
+import {lastItem} from '../../lib/CollectionUtils';
+
+const propTypes = {
+ // Same as FlatList can be any array of anything
+ data: PropTypes.arrayOf(PropTypes.any),
+
+ // Same as FlatList although we wrap it in a measuring helper
+ // before passing to the actual FlatList component
+ renderItem: PropTypes.func.isRequired,
+
+ // This must be set to the minimum size of one of the
+ // renderItem rows. Web will have issues with FlatList
+ // if this is inaccurate.
+ initialRowHeight: PropTypes.number.isRequired,
+};
+
+const defaultProps = {
+ data: [],
+};
+
+class BaseInvertedFlatList extends Component {
+ constructor(props) {
+ super(props);
+
+ this.renderItem = this.renderItem.bind(this);
+ this.getItemLayout = this.getItemLayout.bind(this);
+
+ // Stores each item's computed height after it renders
+ // once and is then referenced for the life of this component.
+ // This is essential to getting FlatList inverted to work on web
+ // and also enables more predictable scrolling on native platforms.
+ this.sizeMap = {};
+ }
+
+ shouldComponentUpdate(prevProps) {
+ // The FlatList itself should only re-render if items are added
+ return prevProps.data.length !== this.props.data.length;
+ }
+
+ /**
+ * Return default or previously cached height for
+ * a renderItem row
+ *
+ * @param {*} data
+ * @param {Number} index
+ *
+ * @return {Object}
+ */
+ getItemLayout(data, index) {
+ const size = this.sizeMap[index];
+
+ if (size) {
+ return {
+ length: size.length,
+ offset: size.offset,
+ index,
+ };
+ }
+
+ // If we don't have a size yet means we haven't measured this
+ // item yet. However, we can still calculate the offset by looking
+ // at the last size we have recorded (if any)
+ const lastMeasuredItem = lastItem(this.sizeMap);
+
+ return {
+ // We haven't measured this so we must return the minimum row height
+ length: this.props.initialRowHeight,
+
+ // Offset will either be based on the lastMeasuredItem or the index +
+ // initialRowHeight since we can only assume that all previous items
+ // have not yet been measured
+ offset: _.isUndefined(lastMeasuredItem)
+ ? this.props.initialRowHeight * index
+ : lastMeasuredItem.offset + this.props.initialRowHeight,
+ index
+ };
+ }
+
+ /**
+ * Measure item and cache the returned length (a.k.a. height)
+ *
+ * @param {React.NativeSyntheticEvent} nativeEvent
+ * @param {Number} index
+ */
+ measureItemLayout(nativeEvent, index) {
+ const computedHeight = nativeEvent.layout.height;
+
+ // We've already measured this item so we don't need to
+ // measure it again.
+ if (this.sizeMap[index]) {
+ return;
+ }
+
+ const previousItem = this.sizeMap[index - 1] || {};
+
+ // If there is no previousItem this can mean we haven't yet measured
+ // the previous item or that we are at index 0 and there is no previousItem
+ const previousLength = previousItem.length || 0;
+ const previousOffset = previousItem.offset || 0;
+ this.sizeMap[index] = {
+ length: computedHeight,
+ offset: previousLength + previousOffset,
+ };
+ }
+
+ /**
+ * Render item method wraps the prop renderItem to render in a
+ * View component so we can attach an onLayout handler and
+ * measure it when it renders.
+ *
+ * @param {Object} params
+ * @param {Object} params.item
+ * @param {Number} params.index
+ *
+ * @return {React.Component}
+ */
+ renderItem({item, index}) {
+ return (
+ this.measureItemLayout(nativeEvent, index)}>
+ {this.props.renderItem({item, index})}
+
+ );
+ }
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+BaseInvertedFlatList.propTypes = propTypes;
+BaseInvertedFlatList.defaultProps = defaultProps;
+
+export default forwardRef((props, ref) => (
+ // eslint-disable-next-line react/jsx-props-no-spreading
+
+));
diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.js
new file mode 100644
index 000000000000..baa394645e27
--- /dev/null
+++ b/src/components/InvertedFlatList/index.js
@@ -0,0 +1,58 @@
+import React, {
+ useEffect,
+ useRef,
+ useCallback,
+ forwardRef
+} from 'react';
+import BaseInvertedFlatList from './BaseInvertedFlatList';
+
+// This is copied from https://codesandbox.io/s/react-native-dsyse
+// It's a HACK alert since FlatList has inverted scrolling on web
+const InvertedFlatList = (props) => {
+ const ref = useRef(null);
+
+ const invertedWheelEvent = useCallback((e) => {
+ ref.current.getScrollableNode().scrollTop -= e.deltaY;
+ e.preventDefault();
+ }, []);
+
+ useEffect(() => {
+ props.forwardedRef(ref);
+ }, []);
+
+ useEffect(() => {
+ const currentRef = ref.current;
+ if (currentRef != null) {
+ currentRef
+ .getScrollableNode()
+ .addEventListener('wheel', invertedWheelEvent);
+
+ currentRef.setNativeProps({
+ style: {
+ transform: 'translate3d(0,0,0) scaleY(-1)'
+ },
+ });
+ }
+
+ return () => {
+ if (currentRef != null) {
+ currentRef
+ .getScrollableNode()
+ .removeEventListener('wheel', invertedWheelEvent);
+ }
+ };
+ }, [ref, invertedWheelEvent]);
+
+ return (
+
+ );
+};
+
+export default forwardRef((props, ref) => (
+ // eslint-disable-next-line react/jsx-props-no-spreading
+
+));
diff --git a/src/components/InvertedFlatList/index.native.js b/src/components/InvertedFlatList/index.native.js
new file mode 100644
index 000000000000..a98fa587721b
--- /dev/null
+++ b/src/components/InvertedFlatList/index.native.js
@@ -0,0 +1,4 @@
+import BaseInvertedFlatList from './BaseInvertedFlatList';
+
+BaseInvertedFlatList.displayName = 'InvertedFlatList';
+export default BaseInvertedFlatList;
diff --git a/src/lib/CollectionUtils.js b/src/lib/CollectionUtils.js
new file mode 100644
index 000000000000..b4501d1a5dee
--- /dev/null
+++ b/src/lib/CollectionUtils.js
@@ -0,0 +1,18 @@
+import _ from 'underscore';
+
+/**
+ * Return the highest item in a numbered collection
+ *
+ * e.g. {1: '1', 2: '2', 3: '3'} -> '3'
+ *
+ * @param {Object} object
+ * @return {*}
+ */
+export function lastItem(object = {}) {
+ const lastKey = _.last(_.keys(object)) || 0;
+ return object[lastKey];
+}
+
+export default {
+ lastItem,
+};
diff --git a/src/page/home/MainView.js b/src/page/home/MainView.js
index 8aaadf4c8afd..0893326eba5b 100644
--- a/src/page/home/MainView.js
+++ b/src/page/home/MainView.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, {Component} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
@@ -26,19 +26,13 @@ const defaultProps = {
reports: {},
};
-class MainView extends React.Component {
+class MainView extends Component {
render() {
- if (!_.size(this.props.reports)) {
- return null;
- }
-
- const reportIDInURL = parseInt(this.props.match.params.reportID, 10);
-
// The styles for each of our reports. Basically, they are all hidden except for the one matching the
// reportID in the URL
let activeReportID;
const reportStyles = _.reduce(this.props.reports, (memo, report) => {
- const isActiveReport = reportIDInURL === report.reportID;
+ const isActiveReport = parseInt(this.props.match.params.reportID, 10) === report.reportID;
const finalData = {...memo};
let reportStyle;
diff --git a/src/page/home/report/ReportActionItem.js b/src/page/home/report/ReportActionItem.js
index cf9407c77ecd..a81e2e703280 100644
--- a/src/page/home/report/ReportActionItem.js
+++ b/src/page/home/report/ReportActionItem.js
@@ -1,7 +1,5 @@
-import React from 'react';
-import {View} from 'react-native';
+import React, {Component} from 'react';
import PropTypes from 'prop-types';
-import _ from 'underscore';
import ReportActionItemSingle from './ReportActionItemSingle';
import ReportActionPropTypes from './ReportActionPropTypes';
import ReportActionItemGrouped from './ReportActionItemGrouped';
@@ -14,24 +12,19 @@ const propTypes = {
displayAsGroup: PropTypes.bool.isRequired,
};
-class ReportActionItem extends React.Component {
+class ReportActionItem extends Component {
shouldComponentUpdate(nextProps) {
- // This component should only render if the action's sequenceNumber or displayAsGroup props change
- return nextProps.displayAsGroup !== this.props.displayAsGroup
- || !_.isEqual(nextProps.action, this.props.action);
+ // If the grouping changes then we want to update the UI
+ return nextProps.displayAsGroup !== this.props.displayAsGroup;
}
render() {
- const {action, displayAsGroup} = this.props;
- if (action.actionName !== 'ADDCOMMENT') {
- return null;
- }
-
return (
-
- {!displayAsGroup && }
- {displayAsGroup && }
-
+ <>
+ {!this.props.displayAsGroup
+ ?
+ : }
+ >
);
}
}
diff --git a/src/page/home/report/ReportActionItemGrouped.js b/src/page/home/report/ReportActionItemGrouped.js
index 3ed21a4de5f7..10601f7aa761 100644
--- a/src/page/home/report/ReportActionItemGrouped.js
+++ b/src/page/home/report/ReportActionItemGrouped.js
@@ -16,9 +16,7 @@ class ReportActionItemGrouped extends React.PureComponent {
return (
-
-
-
+
);
diff --git a/src/page/home/report/ReportActionItemMessage.js b/src/page/home/report/ReportActionItemMessage.js
index 906596e880ff..c185b4fca784 100644
--- a/src/page/home/report/ReportActionItemMessage.js
+++ b/src/page/home/report/ReportActionItemMessage.js
@@ -1,6 +1,8 @@
import React from 'react';
+import {View} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
+import styles from '../../../style/StyleSheet';
import ReportActionItemFragment from './ReportActionItemFragment';
import ReportActionPropTypes from './ReportActionPropTypes';
@@ -10,15 +12,15 @@ const propTypes = {
};
const ReportActionItemMessage = ({action}) => (
- <>
- {_.map(_.compact(action.message), fragment => (
+
+ {_.map(_.compact(action.message), (fragment, index) => (
))}
- >
+
);
ReportActionItemMessage.propTypes = propTypes;
diff --git a/src/page/home/report/ReportActionItemSingle.js b/src/page/home/report/ReportActionItemSingle.js
index abe3beeb690d..8d265780cd9e 100644
--- a/src/page/home/report/ReportActionItemSingle.js
+++ b/src/page/home/report/ReportActionItemSingle.js
@@ -22,28 +22,21 @@ class ReportActionItemSingle extends React.PureComponent {
: action.avatar;
return (
-
-
-
-
-
+
- {action.person.map(fragment => (
-
-
-
+ {_.map(action.person, (fragment, index) => (
+
))}
-
-
-
-
-
-
+
+
);
diff --git a/src/page/home/report/ReportActionsView.js b/src/page/home/report/ReportActionsView.js
index 02495ff5188b..18b2ffead18d 100644
--- a/src/page/home/report/ReportActionsView.js
+++ b/src/page/home/report/ReportActionsView.js
@@ -1,5 +1,5 @@
import React from 'react';
-import {View, ScrollView, Keyboard} from 'react-native';
+import {View, Keyboard} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
import lodashGet from 'lodash.get';
@@ -9,22 +9,21 @@ import {fetchActions, updateLastReadActionID} from '../../../lib/actions/Report'
import IONKEYS from '../../../IONKEYS';
import ReportActionItem from './ReportActionItem';
import styles from '../../../style/StyleSheet';
-import {withRouter} from '../../../lib/Router';
import ReportActionPropTypes from './ReportActionPropTypes';
-import compose from '../../../lib/compose';
+import InvertedFlatList from '../../../components/InvertedFlatList';
+import {lastItem} from '../../../lib/CollectionUtils';
const propTypes = {
- // These are from withRouter
- // eslint-disable-next-line react/forbid-prop-types
- match: PropTypes.object.isRequired,
-
// The ID of the report actions will be created for
reportID: PropTypes.number.isRequired,
+ // Is this report currently in view?
+ isActiveReport: PropTypes.bool.isRequired,
+
/* Ion Props */
// Array of report actions for this report
- reportActions: PropTypes.PropTypes.objectOf(PropTypes.shape(ReportActionPropTypes)),
+ reportActions: PropTypes.objectOf(PropTypes.shape(ReportActionPropTypes)),
};
const defaultProps = {
@@ -35,10 +34,10 @@ class ReportActionsView extends React.Component {
constructor(props) {
super(props);
+ this.renderItem = this.renderItem.bind(this);
this.scrollToListBottom = this.scrollToListBottom.bind(this);
this.recordMaxAction = this.recordMaxAction.bind(this);
-
- this.updateSortedReportActions();
+ this.sortedReportActions = this.updateSortedReportActions();
}
componentDidMount() {
@@ -47,12 +46,18 @@ class ReportActionsView extends React.Component {
}
componentDidUpdate(prevProps) {
- const isReportVisible = this.props.reportID === parseInt(this.props.match.params.reportID, 10);
-
- // When the number of actions change, 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 (isReportVisible && _.size(prevProps.reportActions) !== _.size(this.props.reportActions)) {
- setTimeout(this.recordMaxAction, 3000);
+ if (_.size(prevProps.reportActions) !== _.size(this.props.reportActions)) {
+ // 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.
+ if (lastItem(this.props.reportActions).actorEmail === this.props.session.email) {
+ this.scrollToListBottom();
+ }
+
+ // When the number of actions change, 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 (this.props.isActiveReport) {
+ setTimeout(this.recordMaxAction, 3000);
+ }
}
}
@@ -64,7 +69,12 @@ class ReportActionsView extends React.Component {
* Updates and sorts the report actions by sequence number
*/
updateSortedReportActions() {
- this.sortedReportActions = _.chain(this.props.reportActions).sortBy('sequenceNumber').value();
+ this.sortedReportActions = _.chain(this.props.reportActions)
+ .sortBy('sequenceNumber')
+ .filter(action => action.actionName === 'ADDCOMMENT')
+ .map((item, index) => ({action: item, index}))
+ .value()
+ .reverse();
}
/**
@@ -79,12 +89,7 @@ class ReportActionsView extends React.Component {
* @return {Boolean}
*/
isConsecutiveActionMadeByPreviousActor(actionIndex) {
- // This is the created action and the very first action so it cannot be a consecutive comment.
- if (actionIndex === 0) {
- return false;
- }
-
- const previousAction = this.sortedReportActions[actionIndex - 1];
+ 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
@@ -93,17 +98,12 @@ class ReportActionsView extends React.Component {
return false;
}
- // Only comments that follow other comments are consecutive
- if (previousAction.actionName !== 'ADDCOMMENT' || currentAction.actionName !== 'ADDCOMMENT') {
- return false;
- }
-
// Comments are only grouped if they happen within 5 minutes of each other
- if (currentAction.timestamp - previousAction.timestamp > 300) {
+ if (currentAction.action.timestamp - previousAction.action.timestamp > 300) {
return false;
}
- return currentAction.actorEmail === previousAction.actorEmail;
+ return currentAction.action.actorEmail === previousAction.action.actorEmail;
}
/**
@@ -127,13 +127,49 @@ class ReportActionsView extends React.Component {
*/
scrollToListBottom() {
if (this.actionListElement) {
- this.actionListElement.scrollToEnd({animated: false});
+ this.actionListElement.scrollToIndex({animated: false, index: 0});
}
this.recordMaxAction();
}
+ /**
+ * 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
+ * @param {Function} args.onLayout
+ * @param {Boolean} args.needsLayoutCalculation
+ *
+ * @returns {React.Component}
+ */
+ renderItem({
+ item,
+ index,
+ onLayout,
+ needsLayoutCalculation
+ }) {
+ return (
+
+ );
+ }
+
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 (
Be the first person to comment!
@@ -143,22 +179,14 @@ class ReportActionsView extends React.Component {
this.updateSortedReportActions();
return (
- {
- this.actionListElement = el;
- }}
- onContentSizeChange={this.scrollToListBottom}
- bounces={false}
+ this.actionListElement = el}
+ data={this.sortedReportActions}
+ renderItem={this.renderItem}
contentContainerStyle={[styles.chatContentScrollView]}
- >
- {_.map(this.sortedReportActions, (item, index) => (
-
- ))}
-
+ keyExtractor={item => `${item.action.sequenceNumber}`}
+ initialRowHeight={32}
+ />
);
}
}
@@ -166,11 +194,11 @@ class ReportActionsView extends React.Component {
ReportActionsView.propTypes = propTypes;
ReportActionsView.defaultProps = defaultProps;
-export default compose(
- withRouter,
- withIon({
- reportActions: {
- key: ({reportID}) => `${IONKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
- },
- }),
-)(ReportActionsView);
+export default withIon({
+ reportActions: {
+ key: ({reportID}) => `${IONKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ },
+ session: {
+ key: IONKEYS.SESSION,
+ },
+})(ReportActionsView);
diff --git a/src/page/home/report/ReportView.js b/src/page/home/report/ReportView.js
index 642afd1df829..4032f706c71d 100644
--- a/src/page/home/report/ReportView.js
+++ b/src/page/home/report/ReportView.js
@@ -24,7 +24,10 @@ class ReportView extends React.PureComponent {
const shouldShowComposeForm = this.props.isActiveReport;
return (
-
+
{shouldShowComposeForm && (