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 && (