Improve scrolling to new messages (#1345)
* Improve scrolling to new messages * Experimental scrollTo with measurement * Add search support * Review feedback * Added comment for moreNewMessages index`
This commit is contained in:
parent
dd180fdef7
commit
cbf3739657
5 changed files with 167 additions and 40 deletions
|
|
@ -5,7 +5,7 @@ import {bindActionCreators} from 'redux';
|
|||
import {connect} from 'react-redux';
|
||||
|
||||
import {refreshChannelWithRetry} from 'app/actions/views/channel';
|
||||
import {makePreparePostIdsForPostList} from 'app/selectors/post_list';
|
||||
import {makePreparePostIdsForPostList, START_OF_NEW_MESSAGES} from 'app/selectors/post_list';
|
||||
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
|
|
@ -13,10 +13,16 @@ import PostList from './post_list';
|
|||
|
||||
function makeMapStateToProps() {
|
||||
const preparePostIds = makePreparePostIdsForPostList();
|
||||
|
||||
return (state, ownProps) => {
|
||||
const postIds = preparePostIds(state, ownProps);
|
||||
const measureCellLayout = postIds.indexOf(START_OF_NEW_MESSAGES) > -1 || Boolean(ownProps.highlightPostId);
|
||||
|
||||
const {deviceHeight} = state.device.dimension;
|
||||
|
||||
return {
|
||||
postIds: preparePostIds(state, ownProps),
|
||||
deviceHeight,
|
||||
measureCellLayout,
|
||||
postIds,
|
||||
theme: getTheme(state)
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,15 +14,29 @@ import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
|||
function NewMessagesDivider(props) {
|
||||
const style = getStyleFromTheme(props.theme);
|
||||
|
||||
let text = (
|
||||
<FormattedText
|
||||
id='posts_view.newMsg'
|
||||
defaultMessage='New Messages'
|
||||
style={style.text}
|
||||
/>
|
||||
);
|
||||
|
||||
if (props.moreMessages) {
|
||||
text = (
|
||||
<FormattedText
|
||||
id='mobile.posts_view.moreMsg'
|
||||
defaultMessage='More New Messages Above'
|
||||
style={style.text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[style.container, props.style]}>
|
||||
<View style={style.line}/>
|
||||
<View style={style.textContainer}>
|
||||
<FormattedText
|
||||
id='posts_view.newMsg'
|
||||
defaultMessage='New Messages'
|
||||
style={style.text}
|
||||
/>
|
||||
{text}
|
||||
</View>
|
||||
<View style={style.line}/>
|
||||
</View>
|
||||
|
|
@ -30,6 +44,7 @@ function NewMessagesDivider(props) {
|
|||
}
|
||||
|
||||
NewMessagesDivider.propTypes = {
|
||||
moreMessages: PropTypes.bool,
|
||||
style: ViewPropTypes.style,
|
||||
theme: PropTypes.object
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ import {makeExtraData} from 'app/utils/list_view';
|
|||
import DateHeader from './date_header';
|
||||
import LoadMorePosts from './load_more_posts';
|
||||
import NewMessagesDivider from './new_messages_divider';
|
||||
import withLayout from './with_layout';
|
||||
|
||||
const DateHeaderWithLayout = withLayout(DateHeader);
|
||||
const NewMessagesDividerWithLayout = withLayout(NewMessagesDivider);
|
||||
const PostWithLayout = withLayout(Post);
|
||||
|
||||
const INITAL_BATCH_TO_RENDER = 15;
|
||||
|
||||
export default class PostList extends PureComponent {
|
||||
static propTypes = {
|
||||
|
|
@ -27,11 +34,13 @@ export default class PostList extends PureComponent {
|
|||
}).isRequired,
|
||||
channelId: PropTypes.string,
|
||||
currentUserId: PropTypes.string,
|
||||
deviceHeight: PropTypes.number.isRequired,
|
||||
highlightPostId: PropTypes.string,
|
||||
indicateNewMessages: PropTypes.bool,
|
||||
isSearchResult: PropTypes.bool,
|
||||
lastViewedAt: PropTypes.number, // Used by container // eslint-disable-line no-unused-prop-types
|
||||
loadMore: PropTypes.func,
|
||||
measureCellLayout: PropTypes.bool,
|
||||
navigator: PropTypes.object,
|
||||
onPostPress: PropTypes.func,
|
||||
onRefresh: PropTypes.func,
|
||||
|
|
@ -49,9 +58,11 @@ export default class PostList extends PureComponent {
|
|||
newMessagesIndex = -1;
|
||||
scrollToMessageTries = 0;
|
||||
makeExtraData = makeExtraData();
|
||||
itemMeasurements = {};
|
||||
|
||||
state = {
|
||||
managedConfig: {}
|
||||
managedConfig: {},
|
||||
scrollToMessage: false
|
||||
};
|
||||
|
||||
componentWillMount() {
|
||||
|
|
@ -60,20 +71,24 @@ export default class PostList extends PureComponent {
|
|||
|
||||
componentDidMount() {
|
||||
this.setManagedConfig();
|
||||
this.scrollList();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.postIds !== this.props.postIds) {
|
||||
this.newMessagesIndex = -1;
|
||||
}
|
||||
if (this.props.channelId !== nextProps.channelId) {
|
||||
this.itemMeasurements = {};
|
||||
this.newMessageScrolledTo = false;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const initialPosts = !prevProps.postIds.length && prevProps.postIds !== this.props.postIds;
|
||||
if ((prevProps.channelId !== this.props.channelId || initialPosts || this.props.isSearchResult) && this.refs.list) {
|
||||
this.scrollToMessageTries = 0;
|
||||
this.scrollList();
|
||||
if ((prevProps.channelId !== this.props.channelId || initialPosts) && this.refs.list) {
|
||||
this.scrollToBottomOffset();
|
||||
} else if ((this.props.measureCellLayout || this.props.isSearchResult) && this.state.scrollToMessage) {
|
||||
this.scrollListToMessageOffset();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,30 +96,47 @@ export default class PostList extends PureComponent {
|
|||
mattermostManaged.removeEventListener(this.listenerId);
|
||||
}
|
||||
|
||||
scrollList = () => {
|
||||
scrollToBottomOffset = () => {
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
if (this.props.postIds.length && this.newMessagesIndex !== -1) {
|
||||
if (this.refs.list) {
|
||||
this.refs.list.scrollToIndex({
|
||||
index: this.newMessagesIndex,
|
||||
viewPosition: 1,
|
||||
viewOffset: -10,
|
||||
animated: true
|
||||
});
|
||||
}
|
||||
} else if (this.refs.list) {
|
||||
this.refs.list.scrollToOffset({y: 0, animated: false});
|
||||
if (this.refs.list) {
|
||||
this.refs.list.scrollToOffset({offset: 0, animated: false});
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
scrollListFailed = ({index}) => {
|
||||
if (this.scrollToMessageTries < 3) {
|
||||
this.scrollToMessageTries++;
|
||||
setTimeout(() => {
|
||||
this.newMessagesIndex = index;
|
||||
this.scrollList();
|
||||
}, 300);
|
||||
getMeasurementOffset = (index) => {
|
||||
const orderedKeys = Object.keys(this.itemMeasurements).sort().slice(0, index);
|
||||
return orderedKeys.map((i) => this.itemMeasurements[i]).reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
scrollListToMessageOffset = () => {
|
||||
const index = this.moreNewMessages ? this.props.postIds.length : this.newMessagesIndex;
|
||||
if (index !== -1) {
|
||||
let offset = this.getMeasurementOffset(index);
|
||||
|
||||
const windowHeight = this.state.postListHeight;
|
||||
if (index !== this.props.postIds.length - 1) {
|
||||
if (offset < windowHeight) {
|
||||
return; // no need to scroll since item is in view
|
||||
} else if (offset > windowHeight) {
|
||||
offset = (offset - (windowHeight / 2)) + this.itemMeasurements[index];
|
||||
}
|
||||
}
|
||||
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
if (this.refs.list) {
|
||||
if (!this.moreNewMessages) {
|
||||
this.newMessageScrolledTo = true;
|
||||
}
|
||||
|
||||
this.refs.list.scrollToOffset({offset, animated: true});
|
||||
this.newMessagesIndex = -1;
|
||||
this.moreNewMessages = false;
|
||||
this.setState({
|
||||
scrollToMessage: false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -140,17 +172,37 @@ export default class PostList extends PureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
measureItem = (index, height) => {
|
||||
this.itemMeasurements[index] = height;
|
||||
if (this.props.postIds.length === Object.values(this.itemMeasurements).length) {
|
||||
if (this.newMessagesIndex !== -1 && !this.newMessageScrolledTo) {
|
||||
this.setState({
|
||||
scrollToMessage: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderItem = ({item, index}) => {
|
||||
if (item === START_OF_NEW_MESSAGES) {
|
||||
this.newMessagesIndex = index;
|
||||
|
||||
// postIds includes a date item after the new message indicator so 2
|
||||
// needs to be added to the index for the length check to be correct.
|
||||
this.moreNewMessages = this.props.postIds.length === index + 2;
|
||||
|
||||
return (
|
||||
<NewMessagesDivider
|
||||
<NewMessagesDividerWithLayout
|
||||
index={index}
|
||||
onLayoutCalled={this.measureItem}
|
||||
theme={this.props.theme}
|
||||
moreMessages={this.moreNewMessages}
|
||||
shouldCallOnLayout={this.props.measureCellLayout && !this.newMessageScrolledTo}
|
||||
/>
|
||||
);
|
||||
} else if (item.indexOf(DATE_LINE) === 0) {
|
||||
const date = item.substring(DATE_LINE.length);
|
||||
return this.renderDateHeader(new Date(date));
|
||||
return this.renderDateHeader(new Date(date), index);
|
||||
}
|
||||
|
||||
const postId = item;
|
||||
|
|
@ -163,9 +215,14 @@ export default class PostList extends PureComponent {
|
|||
return this.renderPost(postId, previousPostId, nextPostId, index);
|
||||
};
|
||||
|
||||
renderDateHeader = (date) => {
|
||||
renderDateHeader = (date, index) => {
|
||||
return (
|
||||
<DateHeader date={date}/>
|
||||
<DateHeaderWithLayout
|
||||
date={date}
|
||||
index={index}
|
||||
onLayoutCalled={this.measureItem}
|
||||
shouldCallOnLayout={this.props.measureCellLayout && !this.newMessageScrolledTo}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -186,17 +243,20 @@ export default class PostList extends PureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<Post
|
||||
<PostWithLayout
|
||||
postId={postId}
|
||||
previousPostId={previousPostId}
|
||||
nextPostId={nextPostId}
|
||||
highlight={highlight}
|
||||
index={index}
|
||||
renderReplies={renderReplies}
|
||||
isSearchResult={isSearchResult}
|
||||
shouldRenderReplyButton={shouldRenderReplyButton}
|
||||
onPress={onPostPress}
|
||||
navigator={navigator}
|
||||
managedConfig={managedConfig}
|
||||
onLayoutCalled={this.measureItem}
|
||||
shouldCallOnLayout={this.props.measureCellLayout && !this.newMessageScrolledTo}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -223,6 +283,13 @@ export default class PostList extends PureComponent {
|
|||
);
|
||||
};
|
||||
|
||||
onLayout = (event) => {
|
||||
const {height} = event.nativeEvent.layout;
|
||||
this.setState({
|
||||
postListHeight: height
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
channelId,
|
||||
|
|
@ -241,16 +308,17 @@ export default class PostList extends PureComponent {
|
|||
|
||||
return (
|
||||
<FlatList
|
||||
onLayout={this.onLayout}
|
||||
ref='list'
|
||||
data={postIds}
|
||||
extraData={this.makeExtraData(channelId, highlightPostId)}
|
||||
initialNumToRender={15}
|
||||
initialNumToRender={INITAL_BATCH_TO_RENDER}
|
||||
maxToRenderPerBatch={INITAL_BATCH_TO_RENDER + 1}
|
||||
inverted={true}
|
||||
keyExtractor={this.keyExtractor}
|
||||
ListFooterComponent={this.renderFooter}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={Platform.OS === 'ios' ? 0 : 1}
|
||||
onScrollToIndexFailed={this.scrollListFailed}
|
||||
{...refreshControl}
|
||||
renderItem={this.renderItem}
|
||||
contentContainerStyle={styles.postListContent}
|
||||
|
|
|
|||
37
app/components/post_list/with_layout.js
Normal file
37
app/components/post_list/with_layout.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {View} from 'react-native';
|
||||
|
||||
function withLayout(WrappedComponent) {
|
||||
return class WithLayoutComponent extends PureComponent {
|
||||
static propTypes = {
|
||||
index: PropTypes.number.isRequired,
|
||||
onLayoutCalled: PropTypes.func.isRequired,
|
||||
shouldCallOnLayout: PropTypes.bool
|
||||
};
|
||||
|
||||
onLayout = (event) => {
|
||||
const {height} = event.nativeEvent.layout;
|
||||
this.props.onLayoutCalled(this.props.index, height);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {index, onLayoutCalled, shouldCallOnLayout, ...otherProps} = this.props; //eslint-disable-line no-unused-vars
|
||||
|
||||
if (shouldCallOnLayout) {
|
||||
return (
|
||||
<View onLayout={this.onLayout}>
|
||||
<WrappedComponent {...otherProps}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <WrappedComponent {...otherProps}/>;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default withLayout;
|
||||
|
|
@ -2098,6 +2098,7 @@
|
|||
"mobile.post_textbox.empty.message": "You are trying to send an empty message.\nPlease make sure you have a message or at least one attached file.",
|
||||
"mobile.post_textbox.empty.ok": "OK",
|
||||
"mobile.post_textbox.empty.title": "Empty Message",
|
||||
"mobile.posts_view.moreMsg": "More New Messages Above",
|
||||
"mobile.rename_channel.display_name_maxLength": "Channel name must be less than {maxLength, number} characters",
|
||||
"mobile.rename_channel.display_name_minLength": "Channel name must be {minLength, number} or more characters",
|
||||
"mobile.rename_channel.display_name_required": "Channel name is required",
|
||||
|
|
|
|||
Loading…
Reference in a new issue