Changed to pass IDs to PostList instead of posts (#1036)

* Moved PostList to take a list of postIds instead of posts

* Removed usage of selectors that return posts from ChannelPostList and Thread

* Fixed search and switched to use getPostIdsAroundPost selector

* Removed use of selectors that returned posts from emoji reactions

* Updated makePreparePostIdsForPostList to be better memoized

* Fixed filter of join/leave messages

* Added unit tests for makePrepaprePostIdsForPostList

* Check if post edit/delete should be enabled more often

* Updated mattermost-redux version in yarn.lock
This commit is contained in:
Harrison Healey 2017-10-20 17:58:44 -04:00 committed by enahum
parent e84af2bfa7
commit 0e4f9d5825
15 changed files with 532 additions and 201 deletions

View file

@ -2,16 +2,16 @@
// See License.txt for license information.
import {addReaction} from 'mattermost-redux/actions/posts';
import {getPostsInCurrentChannel, makeGetPostsForThread} from 'mattermost-redux/selectors/entities/posts';
import {getPostIdsInCurrentChannel, makeGetPostIdsForThread} from 'mattermost-redux/selectors/entities/posts';
const getPostsForThread = makeGetPostsForThread();
const getPostIdsForThread = makeGetPostIdsForThread();
export function addReactionToLatestPost(emoji, rootId) {
return async (dispatch, getState) => {
const state = getState();
const posts = rootId ? getPostsForThread(state, {rootId}) : getPostsInCurrentChannel(state);
const lastPost = posts[0];
const postIds = rootId ? getPostIdsForThread(state, rootId) : getPostIdsInCurrentChannel(state);
const lastPostId = postIds[0];
dispatch(addReaction(lastPost.id, emoji));
dispatch(addReaction(lastPostId, emoji));
};
}

View file

@ -18,11 +18,38 @@ function mapStateToProps(state, ownProps) {
const {config, license} = state.entities.general;
const roles = getCurrentUserId(state) ? getCurrentUserRoles(state) : '';
let isFirstReply = true;
let isLastReply = true;
let commentedOnPost = null;
if (ownProps.renderReplies && post && post.root_id) {
if (ownProps.previousPostId) {
const previousPost = getPost(state, ownProps.previousPostId);
if (previousPost && (previousPost.id === post.root_id || previousPost.root_id === post.root_id)) {
// Previous post is root post or previous post is in same thread
isFirstReply = false;
} else {
// Last post is not a comment on the same message
commentedOnPost = getPost(state, post.root_id);
}
}
if (ownProps.nextPostId) {
const nextPost = getPost(state, ownProps.nextPostId);
if (nextPost && nextPost.root_id === post.root_id) {
isLastReply = false;
}
}
}
return {
post,
config,
currentUserId: getCurrentUserId(state),
highlight: ownProps.highlight,
post,
isFirstReply,
isLastReply,
commentedOnPost,
license,
roles,
theme: getTheme(state)

View file

@ -41,6 +41,7 @@ class Post extends PureComponent {
intl: intlShape.isRequired,
style: ViewPropTypes.style,
post: PropTypes.object,
postId: PropTypes.string.isRequired, // Used by container // eslint-disable-line no-unused-prop-types
renderReplies: PropTypes.bool,
isFirstReply: PropTypes.bool,
isLastReply: PropTypes.bool,
@ -65,15 +66,27 @@ class Post extends PureComponent {
const {config, license, currentUserId, roles, post} = props;
this.editDisableAction = new DelayedAction(this.handleEditDisable);
this.state = {
canEdit: canEditPost(config, license, currentUserId, post, this.editDisableAction),
canDelete: canDeletePost(config, license, currentUserId, post, isAdmin(roles), isSystemAdmin(roles))
};
if (post) {
this.state = {
canEdit: canEditPost(config, license, currentUserId, post, this.editDisableAction),
canDelete: canDeletePost(config, license, currentUserId, post, isAdmin(roles), isSystemAdmin(roles))
};
} else {
this.state = {
canEdit: false,
canDelete: false
};
}
}
componentWillReceiveProps(nextProps) {
const {config, license, currentUserId, roles, post} = nextProps;
if (post) {
if (nextProps.config !== this.props.config ||
nextProps.license !== this.props.license ||
nextProps.currentUserId !== this.props.currentUserId ||
nextProps.post !== this.props.post ||
nextProps.roles !== this.props.roles) {
const {config, license, currentUserId, roles, post} = nextProps;
this.setState({
canEdit: canEditPost(config, license, currentUserId, post, this.editDisableAction),
canDelete: canDeletePost(config, license, currentUserId, post, isAdmin(roles), isSystemAdmin(roles))

View file

@ -5,13 +5,20 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {refreshChannelWithRetry} from 'app/actions/views/channel';
import {makePreparePostIdsForPostList} from 'app/selectors/post_list';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import PostList from './post_list';
function mapStateToProps(state) {
return {
theme: getTheme(state)
function makeMapStateToProps() {
const preparePostIds = makePreparePostIdsForPostList();
return (state, ownProps) => {
return {
postIds: preparePostIds(state, ownProps),
theme: getTheme(state)
};
};
}
@ -23,4 +30,4 @@ function mapDispatchToProps(dispatch) {
};
}
export default connect(mapStateToProps, mapDispatchToProps)(PostList);
export default connect(makeMapStateToProps, mapDispatchToProps)(PostList);

View file

@ -7,19 +7,16 @@ import {
StyleSheet,
View
} from 'react-native';
import FlatList from 'app/components/inverted_flat_list';
import {General} from 'mattermost-redux/constants';
import {addDatesToPostList} from 'mattermost-redux/utils/post_utils';
import ChannelIntro from 'app/components/channel_intro';
import FlatList from 'app/components/inverted_flat_list';
import Post from 'app/components/post';
import {DATE_LINE, START_OF_NEW_MESSAGES} from 'app/selectors/post_list';
import DateHeader from './date_header';
import LoadMorePosts from './load_more_posts';
import NewMessagesDivider from './new_messages_divider';
const LOAD_MORE_POSTS = 'load-more-posts';
export default class PostList extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
@ -27,14 +24,15 @@ export default class PostList extends PureComponent {
}).isRequired,
channelId: PropTypes.string,
currentUserId: PropTypes.string,
highlightPostId: PropTypes.string,
indicateNewMessages: PropTypes.bool,
isSearchResult: PropTypes.bool,
lastViewedAt: PropTypes.number,
lastViewedAt: PropTypes.number, // Used by container // eslint-disable-line no-unused-prop-types
loadMore: PropTypes.func,
navigator: PropTypes.object,
onPostPress: PropTypes.func,
onRefresh: PropTypes.func,
posts: PropTypes.array.isRequired,
postIds: PropTypes.array.isRequired,
renderReplies: PropTypes.bool,
showLoadMore: PropTypes.bool,
shouldRenderReplyButton: PropTypes.bool,
@ -52,22 +50,13 @@ export default class PostList extends PureComponent {
}
}
getPostsWithDates = () => {
const {posts, indicateNewMessages, currentUserId, lastViewedAt, showLoadMore} = this.props;
const list = addDatesToPostList(posts, {indicateNewMessages, currentUserId, lastViewedAt});
if (showLoadMore) {
return [...list, LOAD_MORE_POSTS];
}
getItem = (data, index) => data[index];
return list;
};
getItemCount = (data) => data.length;
keyExtractor = (item) => {
if (item instanceof Date) {
return item.getTime();
}
return item.id || item;
// All keys are strings (either post IDs or special keys)
return item;
};
onRefresh = () => {
@ -86,19 +75,26 @@ export default class PostList extends PureComponent {
}
};
renderChannelIntro = () => {
const {channelId, navigator, showLoadMore} = this.props;
// FIXME: Only show the channel intro when we are at the very start of the channel
if (channelId && !showLoadMore) {
renderItem = ({item, index}) => {
if (item === START_OF_NEW_MESSAGES) {
return (
<View>
<ChannelIntro navigator={navigator}/>
</View>
<NewMessagesDivider
theme={this.props.theme}
/>
);
} else if (item.indexOf(DATE_LINE) === 0) {
const date = item.substring(DATE_LINE.length);
return this.renderDateHeader(new Date(date));
}
return null;
const postId = item;
// Remember that the list is rendered with item 0 at the bottom so the "previous" post
// comes after this one in the list
const previousPostId = index < this.props.postIds.length - 1 ? this.props.postIds[index + 1] : null;
const nextPostId = index > 0 ? this.props.postIds[index - 1] : null;
return this.renderPost(postId, previousPostId, nextPostId);
};
renderDateHeader = (date) => {
@ -110,32 +106,9 @@ export default class PostList extends PureComponent {
);
};
renderItem = ({item}) => {
if (item instanceof Date) {
return this.renderDateHeader(item);
}
if (item === General.START_OF_NEW_MESSAGES) {
return (
<NewMessagesDivider
theme={this.props.theme}
/>
);
}
if (item === LOAD_MORE_POSTS && this.props.showLoadMore) {
return (
<LoadMorePosts theme={this.props.theme}/>
);
}
return this.renderPost(item);
};
getItem = (data, index) => data[index];
getItemCount = (data) => data.length;
renderPost = (post) => {
renderPost = (postId, previousPostId, nextPostId) => {
const {
highlightPostId,
isSearchResult,
navigator,
onPostPress,
@ -145,22 +118,42 @@ export default class PostList extends PureComponent {
return (
<Post
highlight={post.highlight}
postId={post.id}
postId={postId}
previousPostId={previousPostId}
nextPostId={nextPostId}
highlight={highlightPostId && highlightPostId === postId}
renderReplies={renderReplies}
isFirstReply={post.isFirstReply}
isLastReply={post.isLastReply}
isSearchResult={isSearchResult}
shouldRenderReplyButton={shouldRenderReplyButton}
commentedOnPost={post.commentedOnPost}
onPress={onPostPress}
navigator={navigator}
/>
);
};
renderFooter = () => {
if (this.props.showLoadMore) {
return <LoadMorePosts theme={this.props.theme}/>;
} else if (this.props.channelId) {
// FIXME: Only show the channel intro when we are at the very start of the channel
return (
<View>
<ChannelIntro navigator={this.props.navigator}/>
</View>
);
}
return null;
};
render() {
const {channelId, loadMore, theme} = this.props;
const {
channelId,
highlightPostId,
loadMore,
postIds,
theme
} = this.props;
const refreshControl = {
refreshing: false
@ -170,15 +163,15 @@ export default class PostList extends PureComponent {
refreshControl.onRefresh = this.onRefresh;
}
const data = this.getPostsWithDates();
return (
<FlatList
ref='list'
data={data}
data={postIds}
extraData={highlightPostId}
initialNumToRender={15}
inverted={true}
keyExtractor={this.keyExtractor}
ListFooterComponent={this.renderChannelIntro}
ListFooterComponent={this.renderFooter}
onEndReached={loadMore}
onEndReachedThreshold={0}
{...refreshControl}

View file

@ -3,31 +3,22 @@
import {connect} from 'react-redux';
import {getPost, makeGetPostsAroundPost} from 'mattermost-redux/selectors/entities/posts';
import {getPost, makeGetPostIdsAroundPost} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import SearchPreview from './search_preview';
function makeMapStateToProps() {
const getPostsAroundPost = makeGetPostsAroundPost();
const getPostIdsAroundPost = makeGetPostIdsAroundPost();
return function mapStateToProps(state, ownProps) {
const post = getPost(state, ownProps.focusedPostId);
const postsAroundPost = getPostsAroundPost(state, post.id, post.channel_id);
const focusedPostIndex = postsAroundPost ? postsAroundPost.findIndex((p) => p.id === ownProps.focusedPostId) : -1;
let posts = [];
if (focusedPostIndex !== -1) {
const desiredPostIndexBefore = focusedPostIndex - 5;
const minPostIndex = desiredPostIndexBefore < 0 ? 0 : desiredPostIndexBefore;
posts = [...postsAroundPost].splice(minPostIndex, 10);
}
const postIds = getPostIdsAroundPost(state, post.id, post.channel_id, {postsBeforeCount: 5, postsAfterCount: 5});
return {
...ownProps,
channelId: post.channel_id,
currentUserId: getCurrentUserId(state),
posts
postIds
};
};
}

View file

@ -39,15 +39,16 @@ export default class SearchPreview extends PureComponent {
channelId: PropTypes.string,
channelName: PropTypes.string,
currentUserId: PropTypes.string.isRequired,
focusedPostId: PropTypes.string.isRequired,
navigator: PropTypes.object,
onClose: PropTypes.func,
onPress: PropTypes.func,
posts: PropTypes.array,
postIds: PropTypes.array,
theme: PropTypes.object.isRequired
};
static defaultProps = {
posts: []
postIds: []
};
state = {
@ -57,7 +58,7 @@ export default class SearchPreview extends PureComponent {
componentWillReceiveProps(nextProps) {
const {animationEnded, showPosts} = this.state;
if (animationEnded && !showPosts && nextProps.posts.length) {
if (animationEnded && !showPosts && nextProps.postIds.length) {
this.setState({showPosts: true});
}
}
@ -82,24 +83,31 @@ export default class SearchPreview extends PureComponent {
showPostList = () => {
this.setState({animationEnded: true});
if (!this.state.showPosts && this.props.posts.length) {
if (!this.state.showPosts && this.props.postIds.length) {
this.setState({showPosts: true});
}
};
render() {
const {channelName, currentUserId, posts, theme} = this.props;
const {
channelName,
currentUserId,
focusedPostId,
postIds,
theme
} = this.props;
const style = getStyleSheet(theme);
let postList;
if (this.state.showPosts) {
postList = (
<PostList
highlightPostId={focusedPostId}
indicateNewMessages={false}
isSearchResult={true}
shouldRenderReplyButton={false}
renderReplies={false}
posts={posts}
postIds={postIds}
currentUserId={currentUserId}
lastViewedAt={0}
navigator={navigator}

View file

@ -33,7 +33,7 @@ class ChannelPostList extends PureComponent {
intl: intlShape.isRequired,
lastViewedAt: PropTypes.number,
navigator: PropTypes.object,
posts: PropTypes.array.isRequired,
postIds: PropTypes.array.isRequired,
postVisibility: PropTypes.number,
totalMessageCount: PropTypes.number,
theme: PropTypes.object.isRequired
@ -47,29 +47,29 @@ class ChannelPostList extends PureComponent {
super(props);
this.state = {
visiblePosts: this.getVisiblePosts(props),
showLoadMore: props.posts.length >= props.postVisibility
visiblePostIds: this.getVisiblePostIds(props),
showLoadMore: props.postIds.length >= props.postVisibility
};
}
componentWillReceiveProps(nextProps) {
const {posts: nextPosts} = nextProps;
const {postIds: nextPostIds} = nextProps;
const showLoadMore = nextPosts.length >= nextProps.postVisibility;
let visiblePosts = this.state.visiblePosts;
const showLoadMore = nextPostIds.length >= nextProps.postVisibility;
let visiblePostIds = this.state.visiblePostIds;
if (nextPosts !== this.props.posts || nextProps.postVisibility !== this.props.postVisibility) {
visiblePosts = this.getVisiblePosts(nextProps);
if (nextPostIds !== this.props.postIds || nextProps.postVisibility !== this.props.postVisibility) {
visiblePostIds = this.getVisiblePostIds(nextProps);
}
this.setState({
showLoadMore,
visiblePosts
visiblePostIds
});
}
getVisiblePosts = (props) => {
return props.posts.slice(0, props.postVisibility);
getVisiblePostIds = (props) => {
return props.postIds.slice(0, props.postVisibility);
};
goToThread = (post) => {
@ -130,17 +130,17 @@ class ChannelPostList extends PureComponent {
currentUserId,
lastViewedAt,
navigator,
posts,
postIds,
theme
} = this.props;
const {
showLoadMore,
visiblePosts
visiblePostIds
} = this.state;
let component;
if (!posts.length && channelRefreshingFailed) {
if (!postIds.length && channelRefreshingFailed) {
component = (
<PostListRetry
retry={this.loadPostsRetry}
@ -150,7 +150,7 @@ class ChannelPostList extends PureComponent {
} else {
component = (
<PostList
posts={visiblePosts}
postIds={visiblePostIds}
loadMore={this.loadMorePosts}
showLoadMore={showLoadMore}
onPostPress={this.goToThread}

View file

@ -5,8 +5,7 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {selectPost} from 'mattermost-redux/actions/posts';
import {RequestStatus} from 'mattermost-redux/constants';
import {makeGetPostsInChannel} from 'mattermost-redux/selectors/entities/posts';
import {getPostIdsInCurrentChannel} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentChannelId, getMyCurrentChannelMembership, makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {loadPostsIfNecessaryWithRetry, loadThreadIfNecessary, increasePostVisibility, refreshChannelWithRetry} from 'app/actions/views/channel';
@ -16,11 +15,9 @@ import ChannelPostList from './channel_post_list';
function makeMapStateToProps() {
const getChannel = makeGetChannel();
const getPostsInChannel = makeGetPostsInChannel();
return function mapStateToProps(state) {
const channelId = getCurrentChannelId(state);
const posts = getPostsInChannel(state, channelId) || [];
const channelRefreshingFailed = state.views.channel.retryFailed;
const channel = getChannel(state, {id: channelId}) || {};
@ -30,7 +27,7 @@ function makeMapStateToProps() {
currentUserId: getCurrentUserId(state),
channelType: channel.type,
channelDisplayName: channel.display_name,
posts,
postIds: getPostIdsInCurrentChannel(state),
postVisibility: state.views.channel.postVisibility[channelId],
lastViewedAt: getMyCurrentChannelMembership(state).last_viewed_at,
totalMessageCount: channel.total_msg_count,
@ -51,54 +48,4 @@ function mapDispatchToProps(dispatch) {
};
}
function areStatesEqual(next, prev) {
const nextChannelId = next.entities.channels.currentChannelId;
const prevChannelId = prev.entities.channels.currentChannelId;
if (prevChannelId !== nextChannelId) {
return false;
}
const nextVisible = next.views.channel.postVisibility[nextChannelId];
const prevVisible = prev.views.channel.postVisibility[nextChannelId];
const nextPostsIds = next.entities.posts.postsInChannel[nextChannelId];
const prevPostsIds = prev.entities.posts.postsInChannel[nextChannelId];
// When we don't have posts and we failed to get the post after tha max retry attempts
const prevRetryFailed = prev.views.channel.retryFailed;
const nextRetryFailed = next.views.channel.retryFailed;
if (prevRetryFailed !== nextRetryFailed && nextRetryFailed && !nextPostsIds) {
return false;
}
// if we don't have post and visibility is not set we don't need to re-render
if (!nextPostsIds || !nextVisible || !prevVisible) {
return true;
}
const nextVisiblePostsIds = nextPostsIds.slice(0, nextVisible);
const prevVisiblePostsIds = prevPostsIds ? prevPostsIds.slice(0, prevVisible) : [];
// if we have a different amount of post we should re-render
if (nextVisiblePostsIds.length !== prevVisiblePostsIds.length) {
return false;
}
const {status: nextStatus} = next.requests.posts.getPosts;
const {status: prevStatus} = prev.requests.posts.getPosts;
// if we are requesting post for the first time we should re-render
if (prevStatus === RequestStatus.STARTED && nextStatus === RequestStatus.SUCCESS &&
nextPostsIds.length !== prevPostsIds.length) {
return false;
}
// if at least one post id changed we need to re-render
for (let i = 0; i <= nextVisiblePostsIds.length; i++) {
if (nextVisiblePostsIds[i] && nextVisiblePostsIds[i] !== prevVisiblePostsIds[i]) {
return false;
}
}
return true;
}
export default connect(makeMapStateToProps, mapDispatchToProps, null, {pure: true, areStatesEqual})(ChannelPostList);
export default connect(makeMapStateToProps, mapDispatchToProps)(ChannelPostList);

View file

@ -305,11 +305,8 @@ class Search extends Component {
{displayName}
</Text>
<Post
post={item}
postId={item.id}
renderReplies={true}
isFirstReply={false}
isLastReply={false}
commentedOnPost={null}
onPress={this.previewPost}
onReply={this.goToThread}
isSearchResult={true}

View file

@ -7,24 +7,20 @@ import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {selectPost} from 'mattermost-redux/actions/posts';
import {makeGetPostsForThread} from 'mattermost-redux/selectors/entities/posts';
import {makeGetPostIdsForThread} from 'mattermost-redux/selectors/entities/posts';
import {getMyCurrentChannelMembership} from 'mattermost-redux/selectors/entities/channels';
import Thread from './thread';
function makeMapStateToProps() {
// Create a getPostsForThread selector for each instance of Thread so that each Thread
// is memoized correctly based on its own props
const getPostsForThread = makeGetPostsForThread();
const getPostIdsForThread = makeGetPostIdsForThread();
return function mapStateToProps(state, ownProps) {
const posts = getPostsForThread(state, ownProps);
return {
channelId: ownProps.channelId,
myMember: getMyCurrentChannelMembership(state),
rootId: ownProps.rootId,
posts,
postIds: getPostIdsForThread(state, ownProps.rootId),
theme: getTheme(state)
};
};

View file

@ -20,26 +20,11 @@ export default class Thread extends Component {
myMember: PropTypes.object.isRequired,
rootId: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
posts: PropTypes.array.isRequired
postIds: PropTypes.array.isRequired
};
state = {};
shouldComponentUpdate(nextProps) {
if (nextProps.posts.length !== this.props.posts.length) {
return true;
}
const length = nextProps.posts.length;
for (let i = 0; i < length; i++) {
if (nextProps.posts[i].id !== this.props.posts[i].id) {
return true;
}
}
return false;
}
componentWillReceiveProps(nextProps) {
if (!this.state.lastViewedAt) {
this.setState({lastViewedAt: nextProps.myMember.last_viewed_at});
@ -55,7 +40,7 @@ export default class Thread extends Component {
channelId,
myMember,
navigator,
posts,
postIds,
rootId,
theme
} = this.props;
@ -70,7 +55,7 @@ export default class Thread extends Component {
<StatusBar/>
<PostList
indicateNewMessages={true}
posts={posts}
postIds={postIds}
currentUserId={myMember.user_id}
lastViewedAt={this.state.lastViewedAt}
navigator={navigator}

View file

@ -0,0 +1,79 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {Posts, Preferences} from 'mattermost-redux/constants';
import {makeGetPostsForIds} from 'mattermost-redux/selectors/entities/posts';
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {createIdsSelector} from 'mattermost-redux/utils/helpers';
import {shouldFilterPost} from 'mattermost-redux/utils/post_utils';
export const DATE_LINE = 'date-';
export const START_OF_NEW_MESSAGES = 'start-of-new-messages';
function shouldShowJoinLeaveMessages(state) {
// This setting is true or not set if join/leave messages are to be displayed
return getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true);
}
// Returns a selector that, given the state and an object containing an array of postIds and an optional
// timestamp of when the channel was last read, returns a memoized array of postIds interspersed with
// day indicators and an optional new message indicator.
export function makePreparePostIdsForPostList() {
const getMyPosts = makeGetPostsForIds();
return createIdsSelector(
(state, props) => getMyPosts(state, props.postIds),
(state, props) => props.lastViewedAt,
getCurrentUserId,
shouldShowJoinLeaveMessages,
(posts, lastViewedAt, currentUserId, showJoinLeave) => {
const out = [];
let lastDate = null;
let addedNewMessagesIndicator = false;
const filterOptions = {showJoinLeave};
// Remember that we're iterating through the posts from newest to oldest
for (const post of posts) {
if (post.state === Posts.POST_DELETED && post.user_id === currentUserId) {
continue;
}
// Filter out join/leave messages if necessary
if (shouldFilterPost(post, filterOptions)) {
continue;
}
// Only add the new messages line if a lastViewedAt time is set
if (lastViewedAt && !addedNewMessagesIndicator) {
const postIsUnread = post.create_at > lastViewedAt;
if (postIsUnread) {
out.push(START_OF_NEW_MESSAGES);
addedNewMessagesIndicator = true;
}
}
// Push on a date header if the last post was on a different day than the current one
const postDate = new Date(post.create_at);
if (lastDate && lastDate.toDateString() !== postDate.toDateString()) {
out.push(DATE_LINE + lastDate.toDateString());
}
lastDate = postDate;
out.push(post.id);
}
// Push on the date header for the oldest post
if (lastDate) {
out.push(DATE_LINE + lastDate.toDateString());
}
return out;
}
);
}

View file

@ -0,0 +1,288 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import assert from 'assert';
import {
DATE_LINE,
makePreparePostIdsForPostList,
START_OF_NEW_MESSAGES
} from 'app/selectors/post_list';
import {Posts, Preferences} from 'mattermost-redux/constants';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
describe('Selectors.PostList', () => {
describe('makePreparePostIdsForPostList', () => {
it('filter join/leave posts', () => {
const preparePostIdsForPostList = makePreparePostIdsForPostList();
let state = {
entities: {
posts: {
posts: {
1001: {id: '1001', create_at: 0},
1002: {id: '1002', create_at: 1, type: Posts.POST_TYPES.JOIN_CHANNEL}
}
},
preferences: {
myPreferences: {}
},
users: {
currentUserId: '1234'
}
}
};
const lastViewedAt = Number.POSITIVE_INFINITY;
const postIds = ['1001', '1002'];
// Defaults to show post
let now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.ok(now.indexOf('1001') !== -1);
assert.ok(now.indexOf('1002') !== -1);
// Show join/leave posts
state = {
...state,
entities: {
...state.entities,
preferences: {
...state.entities.preferences,
myPreferences: {
...state.entities.preferences.myPreferences,
[getPreferenceKey(Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE)]: {
category: Preferences.CATEGORY_ADVANCED_SETTINGS,
name: Preferences.ADVANCED_FILTER_JOIN_LEAVE,
value: 'true'
}
}
}
}
};
now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.ok(now.indexOf('1001') !== -1);
assert.ok(now.indexOf('1002') !== -1);
// Hide join/leave posts
state = {
...state,
entities: {
...state.entities,
preferences: {
...state.entities.preferences,
myPreferences: {
...state.entities.preferences.myPreferences,
[getPreferenceKey(Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE)]: {
category: Preferences.CATEGORY_ADVANCED_SETTINGS,
name: Preferences.ADVANCED_FILTER_JOIN_LEAVE,
value: 'false'
}
}
}
}
};
now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.ok(now.indexOf('1001') !== -1);
assert.ok(now.indexOf('1002') === -1);
});
it('memoization', () => {
const preparePostIdsForPostList = makePreparePostIdsForPostList();
// Posts 7 hours apart so they should appear on multiple days
const initialPosts = {
1001: {id: '1001', create_at: 1 * 60 * 60 * 1000},
1002: {id: '1002', create_at: (1 * 60 * 60 * 1000) + 5},
1003: {id: '1003', create_at: (1 * 60 * 60 * 1000) + 10},
1004: {id: '1004', create_at: 25 * 60 * 60 * 1000},
1005: {id: '1005', create_at: (25 * 60 * 60 * 1000) + 5},
1006: {id: '1006', create_at: (25 * 60 * 60 * 1000) + 10, type: Posts.POST_TYPES.JOIN_CHANNEL}
};
let state = {
entities: {
posts: {
posts: initialPosts
},
preferences: {
myPreferences: {
[getPreferenceKey(Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE)]: {
category: Preferences.CATEGORY_ADVANCED_SETTINGS,
name: Preferences.ADVANCED_FILTER_JOIN_LEAVE,
value: 'true'
}
}
},
users: {
currentUserId: '1234'
}
}
};
let postIds = ['1001', '1003', '1004', '1006'];
let lastViewedAt = initialPosts['1001'].create_at + 1;
let now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.ok(now.indexOf('1001') !== -1);
assert.ok(now.indexOf('1002') === -1);
assert.ok(now.indexOf('1003') !== -1);
assert.ok(now.indexOf('1004') !== -1);
assert.ok(now.indexOf('1005') === -1);
assert.ok(now.indexOf('1006') !== -1);
assert.ok(now.findIndex((id) => id.startsWith(DATE_LINE)) !== -1);
assert.ok(now.indexOf(START_OF_NEW_MESSAGES) !== -1);
// No changes
let prev = now;
now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.equal(now, prev);
assert.ok(now.indexOf('1001') !== -1);
assert.ok(now.indexOf('1002') === -1);
assert.ok(now.indexOf('1003') !== -1);
assert.ok(now.indexOf('1004') !== -1);
assert.ok(now.indexOf('1005') === -1);
assert.ok(now.indexOf('1006') !== -1);
assert.ok(now.findIndex((id) => id.startsWith(DATE_LINE)) !== -1);
assert.ok(now.indexOf(START_OF_NEW_MESSAGES) !== -1);
// lastViewedAt changed slightly
lastViewedAt = initialPosts['1001'].create_at + 2;
prev = now;
now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.equal(now, prev);
assert.ok(now.indexOf('1001') !== -1);
assert.ok(now.indexOf('1002') === -1);
assert.ok(now.indexOf('1003') !== -1);
assert.ok(now.indexOf('1004') !== -1);
assert.ok(now.indexOf('1005') === -1);
assert.ok(now.indexOf('1006') !== -1);
assert.ok(now.findIndex((id) => id.startsWith(DATE_LINE)) !== -1);
assert.ok(now.indexOf(START_OF_NEW_MESSAGES) !== -1);
// lastViewedAt changed a lot
lastViewedAt += initialPosts['1003'].create_at + 1;
prev = now;
now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.notEqual(now, prev);
assert.ok(now.indexOf('1001') !== -1);
assert.ok(now.indexOf('1002') === -1);
assert.ok(now.indexOf('1003') !== -1);
assert.ok(now.indexOf('1004') !== -1);
assert.ok(now.indexOf('1005') === -1);
assert.ok(now.indexOf('1006') !== -1);
assert.ok(now.findIndex((id) => id.startsWith(DATE_LINE)) !== -1);
assert.ok(now.indexOf(START_OF_NEW_MESSAGES) !== -1);
prev = now;
now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.equal(now, prev);
// postIds changed, but still shallowly equal
postIds = [...postIds];
prev = now;
now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.equal(now, prev);
assert.ok(now.indexOf('1001') !== -1);
assert.ok(now.indexOf('1002') === -1);
assert.ok(now.indexOf('1003') !== -1);
assert.ok(now.indexOf('1004') !== -1);
assert.ok(now.indexOf('1005') === -1);
assert.ok(now.indexOf('1006') !== -1);
assert.ok(now.findIndex((id) => id.startsWith(DATE_LINE)) !== -1);
assert.ok(now.indexOf(START_OF_NEW_MESSAGES) !== -1);
// Post changed, not in postIds
state = {
...state,
entities: {
...state.entities,
posts: {
...state.entities.posts,
posts: {
...state.entities.posts.posts,
1007: {id: '1007', create_at: 7 * 60 * 60 * 7 * 1000}
}
}
}
};
prev = now;
now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.equal(now, prev);
assert.ok(now.indexOf('1001') !== -1);
assert.ok(now.indexOf('1002') === -1);
assert.ok(now.indexOf('1003') !== -1);
assert.ok(now.indexOf('1004') !== -1);
assert.ok(now.indexOf('1005') === -1);
assert.ok(now.indexOf('1006') !== -1);
assert.ok(now.findIndex((id) => id.startsWith(DATE_LINE)) !== -1);
assert.ok(now.indexOf(START_OF_NEW_MESSAGES) !== -1);
// Post changed, in postIds
state = {
...state,
entities: {
...state.entities,
posts: {
...state.entities.posts,
posts: {
...state.entities.posts.posts,
1006: {...state.entities.posts.posts['1006'], message: 'abcd'}
}
}
}
};
prev = now;
now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.equal(now, prev);
assert.ok(now.indexOf('1001') !== -1);
assert.ok(now.indexOf('1002') === -1);
assert.ok(now.indexOf('1003') !== -1);
assert.ok(now.indexOf('1004') !== -1);
assert.ok(now.indexOf('1005') === -1);
assert.ok(now.indexOf('1006') !== -1);
assert.ok(now.findIndex((id) => id.startsWith(DATE_LINE)) !== -1);
assert.ok(now.indexOf(START_OF_NEW_MESSAGES) !== -1);
// Filter changed
state = {
...state,
entities: {
...state.entities,
preferences: {
...state.entities.preferences,
myPreferences: {
...state.entities.preferences.myPreferences,
[getPreferenceKey(Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE)]: {
category: Preferences.CATEGORY_ADVANCED_SETTINGS,
name: Preferences.ADVANCED_FILTER_JOIN_LEAVE,
value: 'false'
}
}
}
}
};
prev = now;
now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.notEqual(now, prev);
assert.ok(now.indexOf('1001') !== -1);
assert.ok(now.indexOf('1002') === -1);
assert.ok(now.indexOf('1003') !== -1);
assert.ok(now.indexOf('1004') !== -1);
assert.ok(now.indexOf('1005') === -1);
assert.ok(now.indexOf('1006') === -1);
assert.ok(now.findIndex((id) => id.startsWith(DATE_LINE)) !== -1);
assert.ok(now.indexOf(START_OF_NEW_MESSAGES) !== -1);
prev = now;
now = preparePostIdsForPostList(state, {postIds, lastViewedAt});
assert.equal(now, prev);
});
});
});

View file

@ -3878,7 +3878,7 @@ makeerror@1.0.x:
mattermost-redux@mattermost/mattermost-redux#master:
version "0.0.1"
resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/18fa0a2dc2d0648d0346e01f71f096aa835096ac"
resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/f6d97c8a49e9402fcb5d72604751c9a5ae6d7e5a"
dependencies:
deep-equal "1.0.1"
harmony-reflect "1.5.1"