diff --git a/app/containers/MessageBox/index.js b/app/containers/MessageBox/index.js index cfea7b3a2..70cfb5da4 100644 --- a/app/containers/MessageBox/index.js +++ b/app/containers/MessageBox/index.js @@ -30,7 +30,7 @@ const onlyUnique = function onlyUnique(value, index, self) { typing: status => dispatch(userTyping(status)), clearInput: () => dispatch(clearInput()) })) -export default class MessageBox extends React.Component { +export default class MessageBox extends React.PureComponent { static propTypes = { onSubmit: PropTypes.func.isRequired, rid: PropTypes.string.isRequired, diff --git a/app/containers/Typing.js b/app/containers/Typing.js index e618bde23..85fb01103 100644 --- a/app/containers/Typing.js +++ b/app/containers/Typing.js @@ -19,7 +19,10 @@ const styles = StyleSheet.create({ usersTyping: state.room.usersTyping })) -export default class Typing extends React.PureComponent { +export default class Typing extends React.Component { + shouldComponentUpdate(nextProps) { + return this.props.usersTyping.join() !== nextProps.usersTyping.join(); + } get usersTyping() { const users = this.props.usersTyping.filter(_username => this.props.username !== _username); return users.length ? `${ users.join(' ,') } ${ users.length > 1 ? 'are' : 'is' } typing` : ''; diff --git a/app/containers/message/Markdown.js b/app/containers/message/Markdown.js index 328eaae11..23bd69ee9 100644 --- a/app/containers/message/Markdown.js +++ b/app/containers/message/Markdown.js @@ -25,7 +25,7 @@ const BlockCode = ({ node, state }) => ( {node.content} ); - +const mentionStyle = { color: '#13679a' }; const rules = { username: { order: -1, @@ -38,7 +38,7 @@ const rules = { children: ( alert('Username')} > {node.content} @@ -58,7 +58,7 @@ const rules = { children: ( alert('Room')} > {node.content} diff --git a/app/containers/message/index.js b/app/containers/message/index.js index 8b01fe051..9e54017fc 100644 --- a/app/containers/message/index.js +++ b/app/containers/message/index.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { View, StyleSheet, TouchableHighlight, Text, TouchableOpacity } from 'react-native'; +import { View, TouchableHighlight, Text, TouchableOpacity, Animated } from 'react-native'; import { connect } from 'react-redux'; import Icon from 'react-native-vector-icons/MaterialIcons'; import moment from 'moment'; @@ -15,27 +15,11 @@ import Markdown from './Markdown'; import Url from './Url'; import Reply from './Reply'; import messageStatus from '../../constants/messagesStatus'; +import styles from './styles'; + +const avatar = { marginRight: 10 }; +const flex = { flexDirection: 'row', flex: 1 }; -const styles = StyleSheet.create({ - content: { - flexGrow: 1, - flexShrink: 1 - }, - message: { - padding: 12, - paddingTop: 6, - paddingBottom: 6, - flexDirection: 'row', - transform: [{ scaleY: -1 }] - }, - textInfo: { - fontStyle: 'italic', - color: '#a0a0a0' - }, - editing: { - backgroundColor: '#fff5df' - } -}); @connect(state => ({ message: state.messages.message, @@ -53,7 +37,30 @@ export default class Message extends React.Component { user: PropTypes.object.isRequired, editing: PropTypes.bool, actionsShow: PropTypes.func, - errorActionsShow: PropTypes.func + errorActionsShow: PropTypes.func, + animate: PropTypes.bool + } + + componentWillMount() { + this._visibility = new Animated.Value(this.props.animate ? 0 : 1); + } + componentDidMount() { + if (this.props.animate) { + Animated.timing(this._visibility, { + toValue: 1, + duration: 300 + }).start(); + } + } + componentWillReceiveProps() { + this.extraStyle = this.extraStyle || {}; + if (this.props.item.status === messageStatus.TEMP || this.props.item.status === messageStatus.ERROR) { + this.extraStyle.opacity = 0.3; + } + } + + shouldComponentUpdate(nextProps) { + return this.props.item._updatedAt.toGMTString() !== nextProps.item._updatedAt.toGMTString() || this.props.item.status !== nextProps.item.status; } onLongPress() { @@ -157,11 +164,14 @@ export default class Message extends React.Component { item, message, editing, baseUrl } = this.props; - const extraStyle = {}; - if (item.status === messageStatus.TEMP || item.status === messageStatus.ERROR) { - extraStyle.opacity = 0.3; - } - + const marginLeft = this._visibility.interpolate({ + inputRange: [0, 1], + outputRange: [-30, 0] + }); + const opacity = this._visibility.interpolate({ + inputRange: [0, 1], + outputRange: [0, 1] + }); const username = item.alias || item.u.username; const isEditing = message._id === item._id && editing; @@ -176,11 +186,11 @@ export default class Message extends React.Component { style={[styles.message, isEditing ? styles.editing : null]} accessibilityLabel={accessibilityLabel} > - + {this.renderError()} - + - + ); } diff --git a/app/containers/message/styles.js b/app/containers/message/styles.js new file mode 100644 index 000000000..10b2a6663 --- /dev/null +++ b/app/containers/message/styles.js @@ -0,0 +1,22 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + content: { + flexGrow: 1, + flexShrink: 1 + }, + message: { + padding: 12, + paddingTop: 6, + paddingBottom: 6, + flexDirection: 'row', + transform: [{ scaleY: -1 }] + }, + textInfo: { + fontStyle: 'italic', + color: '#a0a0a0' + }, + editing: { + backgroundColor: '#fff5df' + } +}); diff --git a/app/reducers/server.js b/app/reducers/server.js index f704b22f6..337c393e8 100644 --- a/app/reducers/server.js +++ b/app/reducers/server.js @@ -5,7 +5,7 @@ const initialState = { connected: false, errorMessage: '', failure: false, - server: {} + server: '' }; diff --git a/app/views/RoomView/Header/index.js b/app/views/RoomView/Header/index.js index fc421b666..ef0ba5936 100644 --- a/app/views/RoomView/Header/index.js +++ b/app/views/RoomView/Header/index.js @@ -15,7 +15,7 @@ import styles from './styles'; baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', activeUsers: state.activeUsers })) -export default class extends React.Component { +export default class extends React.PureComponent { static propTypes = { navigation: PropTypes.object.isRequired, user: PropTypes.object.isRequired, diff --git a/app/views/RoomView/ListView.js b/app/views/RoomView/ListView.js new file mode 100644 index 000000000..385e7bcd4 --- /dev/null +++ b/app/views/RoomView/ListView.js @@ -0,0 +1,144 @@ +import { ListView as OldList } from 'realm/react-native'; +import React from 'react'; +import cloneReferencedElement from 'react-clone-referenced-element'; +import { ScrollView, ListView as OldList2 } from 'react-native'; + +const DEFAULT_SCROLL_CALLBACK_THROTTLE = 50; + +export class DataSource extends OldList.DataSource { + getRowData(sectionIndex: number, rowIndex: number): any { + const sectionID = this.sectionIdentities[sectionIndex]; + const rowID = this.rowIdentities[sectionIndex][rowIndex]; + return this._getRowData(this._dataBlob, sectionID, rowID); + } + _calculateDirtyArrays() { // eslint-disable-line + return false; + } +} +export class ListView extends OldList2 { + constructor(props) { + super(props); + this.state = { + curRenderedRowsCount: this.props.initialListSize, + highlightedRow: ({}: Object) + }; + + + this.renderRow = this.renderRow.bind(this); + } + + renderRow(_, sectionId, rowId, ...args) { + const { props } = this; + const item = props.dataSource.getRow(sectionId, rowId); + + // The item could be null because our data is a snapshot and it was deleted. + return item ? props.renderRow(item, sectionId, rowId, ...args) : null; + } + + getInnerViewNode() { + return this.refs.listView.getInnerViewNode(); + } + + scrollTo(...args) { + this.refs.listView.scrollTo(...args); + } + + setNativeProps(props) { + this.refs.listView.setNativeProps(props); + } + static DataSource = DataSource; + render() { + const bodyComponents = []; + + const { dataSource } = this.props; + const allRowIDs = dataSource.rowIdentities; + let rowCount = 0; + // const stickySectionHeaderIndices = []; + + // const { renderSectionHeader } = this.props; + + const header = this.props.renderHeader && this.props.renderHeader(); + const footer = this.props.renderFooter && this.props.renderFooter(); + // let totalIndex = header ? 1 : 0; + + for (let sectionIdx = 0; sectionIdx < allRowIDs.length; sectionIdx += 1) { + const sectionID = dataSource.sectionIdentities[sectionIdx]; + const rowIDs = allRowIDs[sectionIdx]; + if (rowIDs.length === 0) { + continue; // eslint-disable-line + } + + // if (renderSectionHeader) { + // const element = renderSectionHeader( + // dataSource.getSectionHeaderData(sectionIdx), + // sectionID, + // ); + // if (element) { + // bodyComponents.push(React.cloneElement(element, { key: `s_${ sectionID }` }), ); + // if (this.props.stickySectionHeadersEnabled) { + // stickySectionHeaderIndices.push(totalIndex); + // } + // totalIndex++; + // } + // } + + for (let rowIdx = 0; rowIdx < rowIDs.length; rowIdx += 1) { + const rowID = rowIDs[rowIdx]; + const data = dataSource._dataBlob[sectionID][rowID]; + bodyComponents.push(this.props.renderRow.bind( + null, + data, + sectionID, + rowID, + this._onRowHighlighted, + )()); + // totalIndex += 1; + rowCount += 1; + if (rowCount === this.state.curRenderedRowsCount) { + break; + } + } + if (rowCount >= this.state.curRenderedRowsCount) { + break; + } + } + + const { ...props } = this.props; + if (!props.scrollEventThrottle) { + props.scrollEventThrottle = DEFAULT_SCROLL_CALLBACK_THROTTLE; + } + if (props.removeClippedSubviews === undefined) { + props.removeClippedSubviews = true; + } + /* $FlowFixMe(>=0.54.0 site=react_native_fb,react_native_oss) This comment + * suppresses an error found when Flow v0.54 was deployed. To see the error + * delete this comment and run Flow. */ + Object.assign(props, { + onScroll: this._onScroll, + /* $FlowFixMe(>=0.53.0 site=react_native_fb,react_native_oss) This + * comment suppresses an error when upgrading Flow's support for React. + * To see the error delete this comment and run Flow. */ + // stickyHeaderIndices: this.props.stickyHeaderIndices.concat(stickySectionHeaderIndices,), + + // Do not pass these events downstream to ScrollView since they will be + // registered in ListView's own ScrollResponder.Mixin + onKeyboardWillShow: undefined, + onKeyboardWillHide: undefined, + onKeyboardDidShow: undefined, + onKeyboardDidHide: undefined + }); + + return cloneReferencedElement( + , + { + ref: this._setScrollComponentRef, + onContentSizeChange: this._onContentSizeChange, + onLayout: this._onLayout + }, + header, + bodyComponents, + footer, + ); + } +} +ListView.DataSource = DataSource; diff --git a/app/views/RoomView/banner.js b/app/views/RoomView/banner.js new file mode 100644 index 000000000..bc5424ff0 --- /dev/null +++ b/app/views/RoomView/banner.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Text, View } from 'react-native'; +import { connect } from 'react-redux'; +import styles from './styles'; + +@connect(state => ({ + loading: state.messages.isFetching +}), null) +export default class Banner extends React.PureComponent { + static propTypes = { + loading: PropTypes.bool + }; + + render() { + return (this.props.loading ? ( + + Loading new messages... + + ) : null); + } +} diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index 0e9e323b7..92a8a2607 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -1,10 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Text, View, Button, SafeAreaView } from 'react-native'; -import { ListView } from 'realm/react-native'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; +import equal from 'deep-equal'; +import { ListView } from './ListView'; import * as actions from '../../actions'; import { openRoom } from '../../actions/room'; import { editCancel } from '../../actions/messages'; @@ -18,8 +19,11 @@ import Typing from '../../containers/Typing'; import KeyboardView from '../../presentation/KeyboardView'; import Header from '../../containers/Header'; import RoomsHeader from './Header'; +import Banner from './banner'; import styles from './styles'; +import debounce from '../../utils/debounce'; + const ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1._id !== r2._id }); const typing = () => ; @@ -45,8 +49,7 @@ export default class RoomView extends React.Component { rid: PropTypes.string, name: PropTypes.string, Site_Url: PropTypes.string, - Message_TimeFormat: PropTypes.string, - loading: PropTypes.bool + Message_TimeFormat: PropTypes.string }; static navigationOptions = ({ navigation }) => ({ @@ -61,13 +64,15 @@ export default class RoomView extends React.Component { this.name = this.props.name || this.props.navigation.state.params.name || this.props.navigation.state.params.room.name; - - this.data = database.objects('messages') + this.opened = new Date(); + this.data = database + .objects('messages') .filtered('rid = $0', this.rid) .sorted('ts', true); + const rowIds = this.data.map((row, index) => index); this.room = database.objects('subscriptions').filtered('rid = $0', this.rid); this.state = { - dataSource: ds.cloneWithRows([]), + dataSource: ds.cloneWithRows(this.data, rowIds), loaded: true, joined: typeof props.rid === 'undefined' }; @@ -80,8 +85,8 @@ export default class RoomView extends React.Component { this.props.openRoom({ rid: this.rid, name: this.name }); this.data.addListener(this.updateState); } - componentDidMount() { - this.updateState(); + shouldComponentUpdate(nextProps, nextState) { + return !(equal(this.props, nextProps) && equal(this.state, nextState)); } componentWillUnmount() { clearTimeout(this.timer); @@ -90,9 +95,8 @@ export default class RoomView extends React.Component { } onEndReached = () => { - const rowCount = this.state.dataSource.getRowCount(); if ( - rowCount && + // rowCount && this.state.loaded && this.state.loadingMore !== true && this.state.end !== true @@ -100,22 +104,27 @@ export default class RoomView extends React.Component { this.setState({ loadingMore: true }); - - const lastRowData = this.data[rowCount - 1]; - RocketChat.loadMessagesForRoom(this.rid, lastRowData.ts, ({ end }) => { - this.setState({ - loadingMore: false, - end + requestAnimationFrame(() => { + const lastRowData = this.data[this.data.length - 1]; + if (!lastRowData) { + return; + } + RocketChat.loadMessagesForRoom(this.rid, lastRowData.ts, ({ end }) => { + this.setState({ + loadingMore: false, + end + }); }); }); } } - updateState = () => { + updateState = debounce(() => { + const rowIds = this.data.map((row, index) => index); this.setState({ - dataSource: ds.cloneWithRows(this.data) + dataSource: this.state.dataSource.cloneWithRows(this.data, rowIds) }); - }; + }, 50); sendMessage = message => RocketChat.sendMessage(this.rid, message); @@ -126,17 +135,11 @@ export default class RoomView extends React.Component { }); }; - renderBanner = () => - (this.props.loading ? ( - - Loading new messages... - - ) : null); - - renderItem = ({ item }) => ( + renderItem = item => ( - {this.renderBanner()} + + this.renderItem({ item })} + renderRow={item => this.renderItem(item)} initialListSize={10} keyboardShouldPersistTaps='always' keyboardDismissMode='interactive' diff --git a/package.json b/package.json index 6f4fedd38..d4b637804 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,12 @@ "babel-plugin-transform-decorators-legacy": "^1.3.4", "babel-plugin-transform-remove-console": "^6.8.5", "babel-polyfill": "^6.26.0", + "deep-equal": "^1.0.1", "ejson": "^2.1.2", "moment": "^2.20.1", "prop-types": "^15.6.0", "react": "^16.2.0", + "react-clone-referenced-element": "^1.0.1", "react-emojione": "^5.0.0", "react-native": "^0.51.0", "react-native-action-button": "^2.8.3",