From da3ac93c8fdfa464054c3938ee803a26797bac92 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Mon, 15 Apr 2019 17:18:43 -0400 Subject: [PATCH] Reorganize post state and make postsInChannel into a sparse array (#2609) * MM-13957 Reorganize post actions (#2343) * MM-13957 Reorganize post actions * Use postDeleted action creator * Show join/leave messages since combined systed messages have been temporarily removed * Re-add tests for showFlaggedPosts and showPinnedPosts * Add temporary fix for permalink view * Update mattermost-redux * MM-13958/MM-13959 Make postsInChannel into a sparse array (#2411) * MM-13958/MM-13959 Make postsInChannel into a sparse array * Fix unit tests * Fix being unable to load channels with between 30 and 60 posts * Fix unit tests * MM-13960 Re-add support for combined user activity posts (#2465) * Add unit tests for PostList * Remove unnecessary null check * MM-13960 Re-add support for combined user activity posts * Fix being deleted combined posts not disappearing for the user who deletes the post * Fix PostList unit tests * Fix New Messages Below indicator when PostList takes post IDs * Update mattermost-redux * Update mattermost-redux --- actions/integration_actions.test.js | 2 +- actions/post_actions.jsx | 72 +---- actions/post_actions.test.js | 67 +---- actions/post_utils.js | 27 +- actions/post_utils.test.js | 18 +- actions/status_actions.jsx | 11 +- actions/status_actions.test.js | 9 +- actions/views/channel.js | 85 ++++++ actions/views/channel.test.js | 280 +++++++++++++++++- actions/views/create_comment.jsx | 9 +- actions/views/create_comment.test.jsx | 29 +- actions/views/rhs.js | 145 +++------ actions/views/rhs.test.js | 238 ++++++++------- actions/websocket_actions.jsx | 24 +- components/channel_header/channel_header.js | 4 +- components/create_post/create_post.jsx | 5 - components/create_post/index.js | 1 - .../combined_user_activity_post/index.js | 22 ++ .../date_separator/date_separator.jsx | 5 +- .../floating_timestamp.jsx | 0 .../post_view/floating_timestamp/index.js | 26 ++ components/post_view/index.js | 43 ++- .../post_view/new_messages_below/index.js | 45 +++ .../new_messages_below/index.test.js | 81 +++++ .../new_messages_below.jsx} | 5 +- components/post_view/post/index.js | 72 ++++- components/post_view/post/index.test.js | 92 ++++++ components/post_view/post_list.jsx | 218 +++++--------- components/post_view/post_list.test.jsx | 209 ++++++++----- .../__snapshots__/post_list_row.test.jsx.snap | 26 +- .../post_view/post_list_row/post_list_row.jsx | 55 ++-- .../post_list_row/post_list_row.test.jsx | 44 ++- components/rhs_thread/rhs_thread.jsx | 16 +- package-lock.json | 7 +- package.json | 2 +- reducers/views/channel.js | 2 +- reducers/views/rhs.js | 20 +- reducers/views/rhs.test.js | 6 +- selectors/posts.js | 77 +---- utils/constants.jsx | 13 +- utils/post_utils.jsx | 40 ++- 41 files changed, 1321 insertions(+), 831 deletions(-) create mode 100644 components/post_view/combined_user_activity_post/index.js rename components/post_view/{ => floating_timestamp}/floating_timestamp.jsx (100%) create mode 100644 components/post_view/floating_timestamp/index.js create mode 100644 components/post_view/new_messages_below/index.js create mode 100644 components/post_view/new_messages_below/index.test.js rename components/post_view/{new_message_indicator.jsx => new_messages_below/new_messages_below.jsx} (92%) create mode 100644 components/post_view/post/index.test.js diff --git a/actions/integration_actions.test.js b/actions/integration_actions.test.js index 08c575da5e57..65dc3774e761 100644 --- a/actions/integration_actions.test.js +++ b/actions/integration_actions.test.js @@ -113,4 +113,4 @@ describe('actions/integration_actions', () => { expect(getProfilesByIds).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/actions/post_actions.jsx b/actions/post_actions.jsx index b6ef1c2e7ee7..c3f04016e585 100644 --- a/actions/post_actions.jsx +++ b/actions/post_actions.jsx @@ -1,9 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {batchActions} from 'redux-batched-actions'; - -import {PostTypes, SearchTypes} from 'mattermost-redux/action_types'; +import {SearchTypes} from 'mattermost-redux/action_types'; import {getMyChannelMember} from 'mattermost-redux/actions/channels'; import {getChannel, getMyChannelMember as getMyChannelMemberSelector} from 'mattermost-redux/selectors/entities/channels'; import * as PostActions from 'mattermost-redux/actions/posts'; @@ -129,60 +127,6 @@ export function addReaction(postId, emojiName) { }; } -const POST_INCREASE_AMOUNT = Constants.POST_CHUNK_SIZE / 2; - -// Returns true if there are more posts to load -export function increasePostVisibility(channelId, focusedPostId) { - return async (dispatch, getState) => { - const state = getState(); - if (state.views.channel.loadingPosts[channelId]) { - return true; - } - - const currentPostVisibility = state.views.channel.postVisibility[channelId]; - - if (currentPostVisibility >= Constants.MAX_POST_VISIBILITY) { - return true; - } - - dispatch({ - type: ActionTypes.LOADING_POSTS, - data: true, - channelId, - }); - - const page = Math.floor(currentPostVisibility / POST_INCREASE_AMOUNT); - - let result; - if (focusedPostId) { - result = await dispatch(PostActions.getPostsBefore(channelId, focusedPostId, page, POST_INCREASE_AMOUNT)); - } else { - result = await dispatch(PostActions.getPosts(channelId, page, POST_INCREASE_AMOUNT)); - } - const posts = result.data; - - const actions = [{ - type: ActionTypes.LOADING_POSTS, - data: false, - channelId, - }]; - - if (posts) { - actions.push({ - type: ActionTypes.INCREASE_POST_VISIBILITY, - data: channelId, - amount: posts.order.length, - }); - } - - dispatch(batchActions(actions)); - return { - moreToLoad: posts ? posts.order.length >= POST_INCREASE_AMOUNT : false, - error: result.error, - }; - }; -} - export function searchForTerm(term) { return (dispatch) => { dispatch(RhsActions.updateSearchTerms(term)); @@ -289,14 +233,7 @@ export function hideEditPostModal() { export function deleteAndRemovePost(post) { return async (dispatch, getState) => { - const {currentUserId} = getState().entities.users; - - let hardDelete = false; - if (post.user_id === currentUserId) { - hardDelete = true; - } - - const {error} = await dispatch(PostActions.deletePost(post, hardDelete)); + const {error} = await dispatch(PostActions.deletePost(post)); if (error) { return {error}; } @@ -309,10 +246,7 @@ export function deleteAndRemovePost(post) { }); } - dispatch({ - type: PostTypes.REMOVE_POST, - data: post, - }); + dispatch(PostActions.removePost(post)); return {data: true}; }; diff --git a/actions/post_actions.test.js b/actions/post_actions.test.js index 33d82e2d426e..8ca427322c52 100644 --- a/actions/post_actions.test.js +++ b/actions/post_actions.test.js @@ -4,8 +4,9 @@ import thunk from 'redux-thunk'; import configureStore from 'redux-mock-store'; -import {Posts} from 'mattermost-redux/constants'; import {SearchTypes} from 'mattermost-redux/action_types'; +import * as PostActions from 'mattermost-redux/actions/posts'; +import {Posts} from 'mattermost-redux/constants'; import * as Actions from 'actions/post_actions'; import {Constants, ActionTypes, RHSStates} from 'utils/constants'; @@ -16,12 +17,11 @@ jest.mock('mattermost-redux/actions/posts', () => ({ addReaction: (...args) => ({type: 'MOCK_ADD_REACTION', args}), createPost: (...args) => ({type: 'MOCK_CREATE_POST', args}), createPostImmediately: (...args) => ({type: 'MOCK_CREATE_POST_IMMEDIATELY', args}), - getPosts: (...args) => ({type: 'MOCK_GET_POSTS', args}), - getPostsBefore: (...args) => ({type: 'MOCK_GET_POSTS_BEFORE', args}), flagPost: (...args) => ({type: 'MOCK_FLAG_POST', args}), unflagPost: (...args) => ({type: 'MOCK_UNFLAG_POST', args}), pinPost: (...args) => ({type: 'MOCK_PIN_POST', args}), unpinPost: (...args) => ({type: 'MOCK_UNPIN_POST', args}), + receivedNewPost: (...args) => ({type: 'MOCK_RECEIVED_NEW_POST', args}), })); jest.mock('actions/emoji_actions', () => ({ @@ -44,17 +44,10 @@ const POST_CREATED_TIME = Date.now(); // This mocks the Date.now() function so it returns a constant value global.Date.now = jest.fn(() => POST_CREATED_TIME); + const INCREASED_POST_VISIBILITY = {amount: 1, data: 'current_channel_id', type: 'INCREASE_POST_VISIBILITY'}; const STOP_TYPING = {type: 'stop_typing', data: {id: 'current_channel_idundefined', now: POST_CREATED_TIME, userId: 'some_user_id'}}; -function getReceivedPosts(post) { - return { - type: 'RECEIVED_NEW_POST', - data: {...post}, - channelId: post.channel_id, - }; -} - describe('Actions.Posts', () => { const latestPost = { id: 'latest_post_id', @@ -70,7 +63,9 @@ describe('Actions.Posts', () => { [latestPost.id]: latestPost, }, postsInChannel: { - current_channel_id: [latestPost.id], + current_channel_id: [ + {order: [latestPost.id], recent: true}, + ], }, postsInThread: {}, messagesHistory: { @@ -154,10 +149,6 @@ describe('Actions.Posts', () => { posts: { editingPost: {}, }, - channel: { - loadingPosts: {}, - postVisibility: {current_channel_id: 60}, - }, rhs: {searchTerms: ''}, }, }; @@ -172,7 +163,7 @@ describe('Actions.Posts', () => { INCREASED_POST_VISIBILITY, { meta: {batch: true}, - payload: [getReceivedPosts(newPost), STOP_TYPING], + payload: [PostActions.receivedNewPost(newPost), STOP_TYPING], type: 'BATCHING_REDUCER.BATCH', }, ]); @@ -223,48 +214,6 @@ describe('Actions.Posts', () => { expect(testStore.getActions()).toEqual([{type: ActionTypes.HIDE_EDIT_POST_MODAL}]); }); - test('increasePostVisibility', async () => { - const testStore = await mockStore(initialState); - - await testStore.dispatch(Actions.increasePostVisibility('current_channel_id')); - expect(testStore.getActions()).toEqual([ - {channelId: 'current_channel_id', data: true, type: 'LOADING_POSTS'}, - {args: ['current_channel_id', 2, 30], type: 'MOCK_GET_POSTS'}, - { - meta: {batch: true}, - payload: [ - {channelId: 'current_channel_id', data: false, type: 'LOADING_POSTS'}, - ], - type: 'BATCHING_REDUCER.BATCH', - }, - ]); - - await testStore.dispatch(Actions.increasePostVisibility('current_channel_id', 'latest_post_id')); - expect(testStore.getActions()).toEqual([ - {channelId: 'current_channel_id', data: true, type: 'LOADING_POSTS'}, - {args: ['current_channel_id', 2, 30], type: 'MOCK_GET_POSTS'}, - { - meta: {batch: true}, - payload: [ - {channelId: 'current_channel_id', data: false, type: 'LOADING_POSTS'}, - ], - type: 'BATCHING_REDUCER.BATCH', - }, - {channelId: 'current_channel_id', data: true, type: 'LOADING_POSTS'}, - { - args: ['current_channel_id', 'latest_post_id', 2, 30], - type: 'MOCK_GET_POSTS_BEFORE', - }, - { - meta: {batch: true}, - payload: [ - {channelId: 'current_channel_id', data: false, type: 'LOADING_POSTS'}, - ], - type: 'BATCHING_REDUCER.BATCH', - }, - ]); - }); - test('searchForTerm', async () => { const testStore = await mockStore(initialState); diff --git a/actions/post_utils.js b/actions/post_utils.js index 017958683a73..70c2d52c4afe 100644 --- a/actions/post_utils.js +++ b/actions/post_utils.js @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. import {batchActions} from 'redux-batched-actions'; -import {PostTypes} from 'mattermost-redux/action_types'; import { markChannelAsRead, markChannelAsUnread, @@ -28,8 +27,7 @@ export function completePostReceive(post, websocketMessageProps) { const state = getState(); const rootPost = PostSelectors.getPost(state, post.root_id); - const postsInChannel = PostSelectors.getPostIdsInChannel(getState(), post.channel_id); - if (post.root_id && !rootPost && postsInChannel && postsInChannel.length !== 0) { + if (post.root_id && !rootPost) { const {data: posts} = await dispatch(PostActions.getPostThread(post.root_id)); if (posts) { dispatch(lastPostActions(post, websocketMessageProps)); @@ -56,20 +54,17 @@ export function lastPostActions(post, websocketMessageProps) { // Need manual dispatch to remove pending post - const actions = [{ - type: PostTypes.RECEIVED_NEW_POST, - data: { - ...post, + const actions = [ + PostActions.receivedNewPost(post), + { + type: WebsocketEvents.STOP_TYPING, + data: { + id: post.channel_id + post.root_id, + userId: post.user_id, + now: Date.now(), + }, }, - channelId: post.channel_id, - }, { - type: WebsocketEvents.STOP_TYPING, - data: { - id: post.channel_id + post.root_id, - userId: post.user_id, - now: Date.now(), - }, - }]; + ]; dispatch(batchActions(actions)); diff --git a/actions/post_utils.test.js b/actions/post_utils.test.js index 4c52c05de41d..5c0105601aaa 100644 --- a/actions/post_utils.test.js +++ b/actions/post_utils.test.js @@ -4,6 +4,7 @@ import thunk from 'redux-thunk'; import configureStore from 'redux-mock-store'; +import {receivedNewPost} from 'mattermost-redux/actions/posts'; import {Posts} from 'mattermost-redux/constants'; import * as PostActionsUtils from 'actions/post_utils'; @@ -34,17 +35,10 @@ const POST_CREATED_TIME = Date.now(); // This mocks the Date.now() function so it returns a constant value global.Date.now = jest.fn(() => POST_CREATED_TIME); + const INCREASED_POST_VISIBILITY = {amount: 1, data: 'current_channel_id', type: 'INCREASE_POST_VISIBILITY'}; const STOP_TYPING = {type: 'stop_typing', data: {id: 'current_channel_idundefined', now: POST_CREATED_TIME, userId: 'some_user_id'}}; -function getReceivedPosts(post) { - return { - type: 'RECEIVED_NEW_POST', - data: {...post}, - channelId: post.channel_id, - }; -} - describe('actions/post_utils', () => { const latestPost = { id: 'latest_post_id', @@ -59,7 +53,9 @@ describe('actions/post_utils', () => { [latestPost.id]: latestPost, }, postsInChannel: { - current_channel_id: [latestPost.id], + current_channel_id: [ + {order: [latestPost.id], recent: true}, + ], }, postsInThread: {}, messagesHistory: { @@ -105,7 +101,7 @@ describe('actions/post_utils', () => { INCREASED_POST_VISIBILITY, { meta: {batch: true}, - payload: [getReceivedPosts(newPost), STOP_TYPING], + payload: [receivedNewPost(newPost), STOP_TYPING], type: 'BATCHING_REDUCER.BATCH', }, ]); @@ -121,7 +117,7 @@ describe('actions/post_utils', () => { INCREASED_POST_VISIBILITY, { meta: {batch: true}, - payload: [getReceivedPosts(newPost), STOP_TYPING], + payload: [receivedNewPost(newPost), STOP_TYPING], type: 'BATCHING_REDUCER.BATCH', }, ]); diff --git a/actions/status_actions.jsx b/actions/status_actions.jsx index 4ece8b925b51..9348144d7d89 100644 --- a/actions/status_actions.jsx +++ b/actions/status_actions.jsx @@ -17,10 +17,13 @@ export function loadStatusesForChannelAndSidebar() { const channelId = getCurrentChannelId(state); const postsInChannel = getPostsInCurrentChannel(state); - const posts = postsInChannel.slice(0, state.views.channel.postVisibility[channelId] || 0); - for (const post of posts) { - if (post.user_id) { - statusesToLoad[post.user_id] = true; + + if (postsInChannel) { + const posts = postsInChannel.slice(0, state.views.channel.postVisibility[channelId] || 0); + for (const post of posts) { + if (post.user_id) { + statusesToLoad[post.user_id] = true; + } } } diff --git a/actions/status_actions.test.js b/actions/status_actions.test.js index 17cb3ab3c193..e85b2612bad1 100644 --- a/actions/status_actions.test.js +++ b/actions/status_actions.test.js @@ -46,7 +46,12 @@ describe('actions/status_actions', () => { }, posts: { posts: {post_id1: {id: 'post_id1', user_id: 'current_user_id'}, post_id2: {id: 'post_id2', user_id: 'user_id2'}}, - postsInChannel: {channel_id1: ['post_id1', 'post_id2'], channel_id2: []}, + postsInChannel: { + channel_id1: [ + {order: ['post_id1', 'post_id2'], recent: true}, + ], + channel_id2: [], + }, }, preferences: { myPreferences: { @@ -128,4 +133,4 @@ describe('actions/status_actions', () => { expect(getStatusesByIds).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/actions/views/channel.js b/actions/views/channel.js index 6a24bd18a25a..c649c2cf09af 100644 --- a/actions/views/channel.js +++ b/actions/views/channel.js @@ -1,7 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {batchActions} from 'redux-batched-actions'; + import {leaveChannel as leaveChannelRedux, joinChannel, unfavoriteChannel} from 'mattermost-redux/actions/channels'; +import * as PostActions from 'mattermost-redux/actions/posts'; +import {Posts} from 'mattermost-redux/constants'; import {getChannel, getChannelByName, getCurrentChannel, getDefaultChannel} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentRelativeTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUserId, getUserByUsername} from 'mattermost-redux/selectors/entities/users'; @@ -117,3 +121,84 @@ export function autocompleteUsersInChannel(prefix, channelId) { }; } +export function loadInitialPosts(channelId, focusedPostId) { + return async (dispatch) => { + let posts; + let hasMoreBefore = false; + let hasMoreAfter = false; + + if (focusedPostId) { + const result = await dispatch(PostActions.getPostsAround(channelId, focusedPostId, Posts.POST_CHUNK_SIZE / 2)); + + posts = result.data; + + if (posts) { + // If the post is at index i, there are i posts after it and len - i - 1 before it + const numPostsAfter = posts.order.indexOf(focusedPostId); + const numPostsBefore = posts.order.length - numPostsAfter - 1; + + hasMoreBefore = numPostsBefore >= Posts.POST_CHUNK_SIZE / 2; + hasMoreAfter = numPostsAfter >= Posts.POST_CHUNK_SIZE / 2; + } + } else { + const result = await dispatch(PostActions.getPosts(channelId, 0, Posts.POST_CHUNK_SIZE / 2)); + + posts = result.data; + + if (posts) { + hasMoreBefore = posts && posts.order.length >= Posts.POST_CHUNK_SIZE / 2; + } + } + + return { + posts, + hasMoreBefore, + hasMoreAfter, + }; + }; +} + +export function increasePostVisibility(channelId, beforePostId) { + return async (dispatch, getState) => { + const state = getState(); + if (state.views.channel.loadingPosts[channelId]) { + return true; + } + + const currentPostVisibility = state.views.channel.postVisibility[channelId]; + + if (currentPostVisibility >= Constants.MAX_POST_VISIBILITY) { + return true; + } + + dispatch({ + type: ActionTypes.LOADING_POSTS, + data: true, + channelId, + }); + + const result = await dispatch(PostActions.getPostsBefore(channelId, beforePostId, 0, Posts.POST_CHUNK_SIZE / 2)); + const posts = result.data; + + const actions = [{ + type: ActionTypes.LOADING_POSTS, + data: false, + channelId, + }]; + + if (posts) { + actions.push({ + type: ActionTypes.INCREASE_POST_VISIBILITY, + data: channelId, + amount: posts.order.length, + }); + } + + dispatch(batchActions(actions)); + + return { + moreToLoad: posts ? posts.order.length >= Posts.POST_CHUNK_SIZE / 2 : false, + error: result.error, + }; + }; +} diff --git a/actions/views/channel.test.js b/actions/views/channel.test.js index bd0c531d44b5..cdc9a2d54638 100644 --- a/actions/views/channel.test.js +++ b/actions/views/channel.test.js @@ -4,12 +4,14 @@ import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; -import {General} from 'mattermost-redux/constants'; +import {General, Posts} from 'mattermost-redux/constants'; import {leaveChannel} from 'mattermost-redux/actions/channels'; +import * as PostActions from 'mattermost-redux/actions/posts'; import {browserHistory} from 'utils/browser_history'; import * as Actions from 'actions/views/channel'; import {openDirectChannelToUserId} from 'actions/channel_actions.jsx'; +import {ActionTypes} from 'utils/constants.jsx'; const mockStore = configureStore([thunk]); @@ -31,6 +33,8 @@ jest.mock('mattermost-redux/actions/channels', () => ({ }), })); +jest.mock('mattermost-redux/actions/posts'); + jest.mock('selectors/local_storage', () => ({ getLastViewedChannelName: () => 'channel1', })); @@ -64,6 +68,12 @@ describe('channel view actions', () => { myPreferences: {}, }, }, + views: { + channel: { + loadingPosts: {}, + postVisibility: {current_channel_id: 60}, + }, + }, }; let store; @@ -104,4 +114,272 @@ describe('channel view actions', () => { expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/channels/${General.DEFAULT_CHANNEL}`); }); }); + + describe('loadInitialPosts', () => { + describe('without a focused post', () => { + test('should call getPosts and return the results', async () => { + const posts = {posts: {}, order: []}; + + PostActions.getPosts.mockReturnValue(() => ({data: posts})); + + const result = await store.dispatch(Actions.loadInitialPosts('channel')); + + expect(result.posts).toBe(posts); + + expect(PostActions.getPosts).toHaveBeenCalledWith('channel', 0, Posts.POST_CHUNK_SIZE / 2); + }); + + test('when enough posts are received', async () => { + const posts = {posts: {}, order: new Array(Posts.POST_CHUNK_SIZE)}; + + PostActions.getPosts.mockReturnValue(() => ({data: posts})); + + const result = await store.dispatch(Actions.loadInitialPosts('channel')); + + expect(result.hasMoreBefore).toBe(true); + expect(result.hasMoreAfter).toBe(false); + }); + + test('when not enough posts are received', async () => { + const posts = {posts: {}, order: new Array((Posts.POST_CHUNK_SIZE / 2) - 1)}; + + PostActions.getPosts.mockReturnValue(() => ({data: posts})); + + const result = await store.dispatch(Actions.loadInitialPosts('channel')); + + expect(result.hasMoreBefore).toBe(false); + expect(result.hasMoreAfter).toBe(false); + }); + }); + + describe('with a focused post', () => { + test('should call getPostsAround and return the results', async () => { + const posts = {posts: {}, order: []}; + + PostActions.getPostsAround.mockReturnValue(() => ({data: posts})); + + const result = await store.dispatch(Actions.loadInitialPosts('channel', 'post')); + + expect(result.posts).toBe(posts); + + expect(PostActions.getPostsAround).toHaveBeenCalledWith('channel', 'post', Posts.POST_CHUNK_SIZE / 2); + }); + + test('when enough posts are received before and after the focused post', async () => { + const posts = { + posts: {}, + order: [ + ...new Array(Posts.POST_CHUNK_SIZE / 2), // after + 'post', + ...new Array(Posts.POST_CHUNK_SIZE / 2), // before + ], + }; + + PostActions.getPostsAround.mockReturnValue(() => ({data: posts})); + + const result = await store.dispatch(Actions.loadInitialPosts('channel', 'post')); + + expect(result.hasMoreAfter).toBe(true); + expect(result.hasMoreBefore).toBe(true); + }); + + test('when not enough posts are received before the focused post', async () => { + const posts = { + posts: {}, + order: [ + ...new Array(Posts.POST_CHUNK_SIZE / 2), // after + 'post', + ...new Array((Posts.POST_CHUNK_SIZE / 2) - 1), // before + ], + }; + + PostActions.getPostsAround.mockReturnValue(() => ({data: posts})); + + const result = await store.dispatch(Actions.loadInitialPosts('channel', 'post')); + + expect(result.hasMoreAfter).toBe(true); + expect(result.hasMoreBefore).toBe(false); + }); + + test('when not enough posts are received after the focused post', async () => { + const posts = { + posts: {}, + order: [ + ...new Array((Posts.POST_CHUNK_SIZE / 2) - 1), // after + 'post', + ...new Array(Posts.POST_CHUNK_SIZE / 2), // before + ], + }; + + PostActions.getPostsAround.mockReturnValue(() => ({data: posts})); + + const result = await store.dispatch(Actions.loadInitialPosts('channel', 'post')); + + expect(result.hasMoreAfter).toBe(false); + expect(result.hasMoreBefore).toBe(true); + }); + + test('when not enough posts are received before and after the focused post', async () => { + const posts = { + posts: {}, + order: [ + ...new Array((Posts.POST_CHUNK_SIZE / 2) - 1), // after + 'post', + ...new Array((Posts.POST_CHUNK_SIZE / 2) - 1), // before + ], + }; + + PostActions.getPostsAround.mockReturnValue(() => ({data: posts})); + + const result = await store.dispatch(Actions.loadInitialPosts('channel', 'post')); + + expect(result.hasMoreAfter).toBe(false); + expect(result.hasMoreBefore).toBe(false); + }); + }); + }); + + describe('increasePostVisibility', () => { + test('should dispatch the correct actions', async () => { + PostActions.getPostsBefore.mockImplementation((...args) => ({type: 'MOCK_GET_POSTS_BEFORE', args})); + + await store.dispatch(Actions.increasePostVisibility('current_channel_id', 'oldest_post_id')); + + expect(store.getActions()).toEqual([ + {channelId: 'current_channel_id', data: true, type: 'LOADING_POSTS'}, + { + args: ['current_channel_id', 'oldest_post_id', 0, 30], + type: 'MOCK_GET_POSTS_BEFORE', + }, + { + meta: {batch: true}, + payload: [ + {channelId: 'current_channel_id', data: false, type: 'LOADING_POSTS'}, + ], + type: 'BATCHING_REDUCER.BATCH', + }, + ]); + }); + + test('should increase post visibility when receiving posts', async () => { + const channelId = 'channel1'; + const posts = { + posts: {}, + order: new Array(7), + }; + + PostActions.getPostsBefore.mockReturnValue(() => ({data: posts})); + + await store.dispatch(Actions.increasePostVisibility(channelId, 'oldest_post_id')); + + expect(store.getActions()).toContainEqual({ + meta: {batch: true}, + payload: [ + { + type: ActionTypes.LOADING_POSTS, + channelId, + data: false, + }, + { + type: ActionTypes.INCREASE_POST_VISIBILITY, + amount: posts.order.length, + data: channelId, + }, + ], + type: 'BATCHING_REDUCER.BATCH', + }); + }); + + test('should return more to load when enough posts are received', async () => { + const channelId = 'channel1'; + const posts = { + posts: {}, + order: new Array(Posts.POST_CHUNK_SIZE / 2), + }; + + PostActions.getPostsBefore.mockReturnValue(() => ({data: posts})); + + const result = await store.dispatch(Actions.increasePostVisibility(channelId, 'oldest_post_id')); + + expect(result).toEqual({ + moreToLoad: true, + }); + }); + + test('should not return more to load when not enough posts are received', async () => { + const channelId = 'channel1'; + const posts = { + posts: {}, + order: new Array((Posts.POST_CHUNK_SIZE / 2) - 1), + }; + + PostActions.getPostsBefore.mockReturnValue(() => ({data: posts})); + + const result = await store.dispatch(Actions.increasePostVisibility(channelId, 'oldest_post_id')); + + expect(result).toEqual({ + moreToLoad: false, + }); + }); + + test('should return error from getPostsBefore', async () => { + const channelId = 'channel1'; + const error = {message: 'something went wrong'}; + + PostActions.getPostsBefore.mockReturnValue(() => ({error})); + + const result = await store.dispatch(Actions.increasePostVisibility(channelId, 'oldest_post_id')); + + expect(result).toEqual({ + error, + moreToLoad: false, + }); + }); + + test('should do nothing when already loading posts', async () => { + const channelId = 'channel1'; + + store = mockStore({ + ...initialState, + views: { + ...initialState.views, + channel: { + ...initialState.views.channel, + loadingPosts: { + [channelId]: true, + }, + }, + }, + }); + + const result = await store.dispatch(Actions.increasePostVisibility(channelId, 'oldest_post_id')); + + expect(result).toBe(true); + + expect(PostActions.getPostsBefore).not.toHaveBeenCalled(); + }); + + test('should do nothing with too many posts loaded', async () => { + const channelId = 'channel1'; + + store = mockStore({ + ...initialState, + views: { + ...initialState.views, + channel: { + ...initialState.views.channel, + postVisibility: { + [channelId]: 100000000, + }, + }, + }, + }); + + const result = await store.dispatch(Actions.increasePostVisibility(channelId, 'oldest_post_id')); + + expect(result).toBe(true); + + expect(PostActions.getPostsBefore).not.toHaveBeenCalled(); + }); + }); }); diff --git a/actions/views/create_comment.jsx b/actions/views/create_comment.jsx index 20702944adda..872280e729a8 100644 --- a/actions/views/create_comment.jsx +++ b/actions/views/create_comment.jsx @@ -4,7 +4,12 @@ import {createSelector} from 'reselect'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; -import {makeGetMessageInHistoryItem, makeGetCommentCountForPost, getPost} from 'mattermost-redux/selectors/entities/posts'; +import { + makeGetMessageInHistoryItem, + makeGetCommentCountForPost, + getPost, + getPostIdsInChannel, +} from 'mattermost-redux/selectors/entities/posts'; import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis'; import { removeReaction, @@ -153,7 +158,7 @@ export function makeOnSubmit(channelId, rootId, latestPostId) { function makeGetCurrentUsersLatestPost(channelId, rootId) { return createSelector( getCurrentUserId, - (state) => state.entities.posts.postsInChannel[channelId], + (state) => getPostIdsInChannel(state, channelId), (state) => (id) => getPost(state, id), (userId, postIds, getPostById) => { let lastPost = null; diff --git a/actions/views/create_comment.test.jsx b/actions/views/create_comment.test.jsx index 3da2a8b3cdbd..6d61ceb5ae60 100644 --- a/actions/views/create_comment.test.jsx +++ b/actions/views/create_comment.test.jsx @@ -92,7 +92,9 @@ describe('rhs view actions', () => { }, }, postsInChannel: { - [channelId]: [latestPostId], + [channelId]: [ + {order: [latestPostId], recent: true}, + ], }, postsInThread: {}, messagesHistory: { @@ -102,8 +104,14 @@ describe('rhs view actions', () => { messages: ['test message'], }, }, + preferences: { + myPreferences: {}, + }, users: { currentUserId, + profiles: { + [currentUserId]: {id: currentUserId}, + }, }, teams: { currentTeamId: teamId, @@ -442,16 +450,15 @@ describe('rhs view actions', () => { test('it dispatches the correct actions', () => { store.dispatch(onEditLatestPost()); - const testStore = mockStore(initialState); - testStore.dispatch(PostActions.setEditingPost( - latestPostId, - 0, - 'reply_textbox', - 'Comment', - true - )); - - expect(store.getActions()).toEqual(testStore.getActions()); + expect(store.getActions()).toEqual([ + PostActions.setEditingPost( + latestPostId, + 0, + 'reply_textbox', + 'Comment', + true + ), + ]); }); }); }); diff --git a/actions/views/rhs.js b/actions/views/rhs.js index 106e8f9b1b06..76df8917c948 100644 --- a/actions/views/rhs.js +++ b/actions/views/rhs.js @@ -4,9 +4,12 @@ import {batchActions} from 'redux-batched-actions'; import {SearchTypes} from 'mattermost-redux/action_types'; -import {searchPostsWithParams, getFlaggedPosts} from 'mattermost-redux/actions/search'; +import { + getFlaggedPosts, + getPinnedPosts, + searchPostsWithParams, +} from 'mattermost-redux/actions/search'; import * as PostActions from 'mattermost-redux/actions/posts'; -import {Client4} from 'mattermost-redux/client'; import {getCurrentUserId, getCurrentUserMentionKeys} from 'mattermost-redux/selectors/entities/users'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; @@ -93,54 +96,6 @@ export function showSearchResults() { }; } -function getSearchActions(result, teamId) { - return [ - { - type: SearchTypes.RECEIVED_SEARCH_POSTS, - data: result, - }, - { - type: SearchTypes.RECEIVED_SEARCH_TERM, - data: { - teamId, - terms: null, - isOrSearch: false, - }, - }, - { - type: SearchTypes.SEARCH_POSTS_SUCCESS, - }, - ]; -} - -function getPreRHSSearchActions(searchPostRequest, terms, rhsState, channelId) { - const updateRHSState = { - type: ActionTypes.UPDATE_RHS_STATE, - state: rhsState, - }; - - if (channelId) { - updateRHSState.channelId = channelId; - } - - return [ - { - type: searchPostRequest, - }, - { - type: ActionTypes.UPDATE_RHS_SEARCH_TERMS, - terms, - }, - updateRHSState, - ]; -} - -function getPostRHSSearchActions(searchPostSuccess, result, teamId) { - const searchActions = getSearchActions(result, teamId); - - return [...searchActions, {type: searchPostSuccess}]; -} - export function showFlaggedPosts() { return async (dispatch, getState) => { const state = getState(); @@ -151,28 +106,22 @@ export function showFlaggedPosts() { state: RHSStates.FLAG, }); - const result = await dispatch(getFlaggedPosts()); - - const postRHSSearchActions = getSearchActions( - result.data, - teamId - ); - - dispatch(batchActions(postRHSSearchActions)); - }; -} - -export function getPinnedPosts(channelId) { - return async (dispatch, getState) => { - const currentChannelId = getCurrentChannelId(getState()); - const result = await Client4.getPinnedPosts(channelId || currentChannelId); - - await PostActions.getProfilesAndStatusesForPosts(result.posts, dispatch, getState); - - const teamId = getCurrentTeamId(getState()); - const searchActions = getSearchActions(result, teamId); + const results = await dispatch(getFlaggedPosts()); - dispatch(batchActions(searchActions)); + dispatch(batchActions([ + { + type: SearchTypes.RECEIVED_SEARCH_POSTS, + data: results.data, + }, + { + type: SearchTypes.RECEIVED_SEARCH_TERM, + data: { + teamId, + terms: null, + isOrSearch: false, + }, + }, + ])); }; } @@ -180,34 +129,36 @@ export function showPinnedPosts(channelId) { return async (dispatch, getState) => { const state = getState(); const currentChannelId = getCurrentChannelId(state); - - const preRHSSearchActions = getPreRHSSearchActions( - ActionTypes.SEARCH_PINNED_POSTS_REQUEST, - '', - RHSStates.PIN, - currentChannelId - ); - - dispatch(batchActions(preRHSSearchActions)); - - let result; - try { - result = await Client4.getPinnedPosts(channelId || currentChannelId); - } catch (error) { - dispatch({type: ActionTypes.SEARCH_PINNED_POSTS_FAILURE, error}); - } - - await PostActions.getProfilesAndStatusesForPosts(result.posts, dispatch, getState); - const teamId = getCurrentTeamId(state); - const postRHSSearchActions = getPostRHSSearchActions( - ActionTypes.SEARCH_PINNED_POSTS_SUCCESS, - result, - teamId - ); + dispatch(batchActions([ + { + type: ActionTypes.UPDATE_RHS_SEARCH_TERMS, + terms: '', + }, + { + type: ActionTypes.UPDATE_RHS_STATE, + channelId: channelId || currentChannelId, + state: RHSStates.PIN, + }, + ])); - dispatch(batchActions(postRHSSearchActions)); + const results = await dispatch(getPinnedPosts(channelId || currentChannelId)); + + dispatch(batchActions([ + { + type: SearchTypes.RECEIVED_SEARCH_POSTS, + data: results.data, + }, + { + type: SearchTypes.RECEIVED_SEARCH_TERM, + data: { + teamId, + terms: null, + isOrSearch: false, + }, + }, + ])); }; } @@ -278,4 +229,4 @@ export function toggleRhsExpanded() { export function selectPost(post) { return {type: ActionTypes.SELECT_POST, postId: post.root_id || post.id, channelId: post.channel_id}; -} \ No newline at end of file +} diff --git a/actions/views/rhs.test.js b/actions/views/rhs.test.js index 796cbc0e0aac..f294af4db2b4 100644 --- a/actions/views/rhs.test.js +++ b/actions/views/rhs.test.js @@ -5,8 +5,7 @@ import {batchActions} from 'redux-batched-actions'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import * as PostActions from 'mattermost-redux/actions/posts'; -import {searchPostsWithParams, getFlaggedPosts} from 'mattermost-redux/actions/search'; -import {Client4} from 'mattermost-redux/client'; +import * as SearchActions from 'mattermost-redux/actions/search'; import {SearchTypes} from 'mattermost-redux/action_types'; import { @@ -14,7 +13,6 @@ import { selectPostFromRightHandSideSearch, updateSearchTerms, performSearch, - getPinnedPosts, showSearchResults, showFlaggedPosts, showPinnedPosts, @@ -44,28 +42,10 @@ jest.mock('mattermost-redux/actions/posts', () => ({ jest.mock('mattermost-redux/actions/search', () => ({ searchPostsWithParams: (...args) => ({type: 'MOCK_SEARCH_POSTS', args}), - getFlaggedPosts: (...args) => ({type: 'MOCK_GET_FLAGGED_POSTS', args}), + getFlaggedPosts: jest.fn(), + getPinnedPosts: jest.fn(), })); -jest.mock('mattermost-redux/client', () => { - const flaggedPosts = [ - {id: 'post1', channel_id: 'channel1'}, - {id: 'post2', channel_id: 'channel2'}, - ]; - - const pinnedPosts = [ - {id: 'post3', channel_id: 'channel3'}, - {id: 'post4', channel_id: 'channel4'}, - ]; - - return { - Client4: { - getFlaggedPosts: jest.fn(() => ({posts: flaggedPosts, order: [0, 1]})), - getPinnedPosts: jest.fn(() => ({posts: pinnedPosts, order: [1, 0]})), - }, - }; -}); - jest.mock('actions/diagnostics_actions.jsx', () => ({ trackEvent: jest.fn(), })); @@ -188,12 +168,12 @@ describe('rhs view actions', () => { const timeZoneOffset = getBrowserUtcOffset() * 60; const compareStore = mockStore(initialState); - compareStore.dispatch(searchPostsWithParams(currentTeamId, {include_deleted_channels: false, terms, is_or_search: false, time_zone_offset: timeZoneOffset, page: 0, per_page: 20}, true)); + compareStore.dispatch(SearchActions.searchPostsWithParams(currentTeamId, {include_deleted_channels: false, terms, is_or_search: false, time_zone_offset: timeZoneOffset, page: 0, per_page: 20}, true)); expect(store.getActions()).toEqual(compareStore.getActions()); store.dispatch(performSearch(terms, true)); - compareStore.dispatch(searchPostsWithParams(currentTeamId, {include_deleted_channels: false, terms, is_or_search: true, time_zone_offset: timeZoneOffset, page: 0, per_page: 20}, true)); + compareStore.dispatch(SearchActions.searchPostsWithParams(currentTeamId, {include_deleted_channels: false, terms, is_or_search: true, time_zone_offset: timeZoneOffset, page: 0, per_page: 20}, true)); expect(store.getActions()).toEqual(compareStore.getActions()); }); @@ -230,123 +210,159 @@ describe('rhs view actions', () => { describe('showFlaggedPosts', () => { test('it dispatches the right actions', async () => { - function getSearchActions(result, teamId) { - return [ - { - type: SearchTypes.RECEIVED_SEARCH_POSTS, - data: result, - }, - { - type: SearchTypes.RECEIVED_SEARCH_TERM, - data: { - teamId, - terms: null, - isOrSearch: false, - }, - }, - { - type: SearchTypes.SEARCH_POSTS_SUCCESS, - }, - ]; - } + SearchActions.getFlaggedPosts.mockReturnValue((dispatch) => { + dispatch({type: 'MOCK_GET_FLAGGED_POSTS'}); - store.dispatch(showFlaggedPosts()); - - const compareStore = mockStore(initialState); - - compareStore.dispatch({ - type: ActionTypes.UPDATE_RHS_STATE, - state: RHSStates.FLAG, + return {data: 'data'}; }); - const result = await compareStore.dispatch(getFlaggedPosts()); - - const postRHSSearchActions = getSearchActions( - result.data, - currentTeamId - ); - - compareStore.dispatch(batchActions(postRHSSearchActions)); - - expect(store.getActions()).toEqual(compareStore.getActions()); - }); - }); - - describe('getPinnedPosts', () => { - test('it dispatches the right actions', async () => { - await store.dispatch(getPinnedPosts()); + await store.dispatch(showFlaggedPosts()); - const compareStore = mockStore(initialState); - const result = await Client4.getPinnedPosts(currentChannelId); - await PostActions.getProfilesAndStatusesForPosts(result.posts, compareStore.dispatch, compareStore.getState); + expect(SearchActions.getFlaggedPosts).toHaveBeenCalled(); - compareStore.dispatch(batchActions([ + expect(store.getActions()).toEqual([ { - type: SearchTypes.RECEIVED_SEARCH_POSTS, - data: result, + type: ActionTypes.UPDATE_RHS_STATE, + state: RHSStates.FLAG, }, { - type: SearchTypes.RECEIVED_SEARCH_TERM, - data: { - teamId: '321', - terms: null, - isOrSearch: false, - }, + type: 'MOCK_GET_FLAGGED_POSTS', }, { - type: SearchTypes.SEARCH_POSTS_SUCCESS, + type: 'BATCHING_REDUCER.BATCH', + meta: { + batch: true, + }, + payload: [ + { + type: SearchTypes.RECEIVED_SEARCH_POSTS, + data: 'data', + }, + { + type: SearchTypes.RECEIVED_SEARCH_TERM, + data: { + teamId: currentTeamId, + terms: null, + isOrSearch: false, + }, + }, + ], }, - ])); - - expect(store.getActions()).toEqual(compareStore.getActions()); + ]); }); }); describe('showPinnedPosts', () => { - test('it dispatches the right actions', async () => { - store.dispatch(showPinnedPosts()); + test('it dispatches the right actions for the current channel', async () => { + SearchActions.getPinnedPosts.mockReturnValue((dispatch) => { + dispatch({type: 'MOCK_GET_PINNED_POSTS'}); - const compareStore = mockStore(initialState); - const result = await Client4.getPinnedPosts('123'); - await PostActions.getProfilesAndStatusesForPosts(result.posts, compareStore.dispatch, compareStore.getState); + return {data: 'data'}; + }); - compareStore.dispatch(batchActions([ + await store.dispatch(showPinnedPosts()); + + expect(SearchActions.getPinnedPosts).toHaveBeenCalledWith(currentChannelId); + + expect(store.getActions()).toEqual([ { - type: ActionTypes.SEARCH_PINNED_POSTS_REQUEST, + type: 'BATCHING_REDUCER.BATCH', + meta: { + batch: true, + }, + payload: [ + { + type: ActionTypes.UPDATE_RHS_SEARCH_TERMS, + terms: '', + }, + { + type: ActionTypes.UPDATE_RHS_STATE, + channelId: currentChannelId, + state: RHSStates.PIN, + }, + ], }, { - type: ActionTypes.UPDATE_RHS_SEARCH_TERMS, - terms: '', + type: 'MOCK_GET_PINNED_POSTS', }, { - type: ActionTypes.UPDATE_RHS_STATE, - state: RHSStates.PIN, - channelId: '123', + type: 'BATCHING_REDUCER.BATCH', + meta: { + batch: true, + }, + payload: [ + { + type: SearchTypes.RECEIVED_SEARCH_POSTS, + data: 'data', + }, + { + type: SearchTypes.RECEIVED_SEARCH_TERM, + data: { + teamId: currentTeamId, + terms: null, + isOrSearch: false, + }, + }, + ], }, - ])); + ]); + }); - compareStore.dispatch(batchActions([ - { - type: SearchTypes.RECEIVED_SEARCH_POSTS, - data: result, - }, + test('it dispatches the right actions for a specific channel', async () => { + const channelId = 'channel1'; + + SearchActions.getPinnedPosts.mockReturnValue((dispatch) => { + dispatch({type: 'MOCK_GET_PINNED_POSTS'}); + + return {data: 'data'}; + }); + + await store.dispatch(showPinnedPosts(channelId)); + + expect(SearchActions.getPinnedPosts).toHaveBeenCalledWith(channelId); + + expect(store.getActions()).toEqual([ { - type: SearchTypes.RECEIVED_SEARCH_TERM, - data: { - teamId: '321', - terms: null, - isOrSearch: false, + type: 'BATCHING_REDUCER.BATCH', + meta: { + batch: true, }, + payload: [ + { + type: ActionTypes.UPDATE_RHS_SEARCH_TERMS, + terms: '', + }, + { + type: ActionTypes.UPDATE_RHS_STATE, + channelId, + state: RHSStates.PIN, + }, + ], }, { - type: SearchTypes.SEARCH_POSTS_SUCCESS, + type: 'MOCK_GET_PINNED_POSTS', }, { - type: ActionTypes.SEARCH_PINNED_POSTS_SUCCESS, + type: 'BATCHING_REDUCER.BATCH', + meta: { + batch: true, + }, + payload: [ + { + type: SearchTypes.RECEIVED_SEARCH_POSTS, + data: 'data', + }, + { + type: SearchTypes.RECEIVED_SEARCH_TERM, + data: { + teamId: currentTeamId, + terms: null, + isOrSearch: false, + }, + }, + ], }, - ])); - - expect(store.getActions()).toEqual(compareStore.getActions()); + ]); }); }); diff --git a/actions/websocket_actions.jsx b/actions/websocket_actions.jsx index bb04ccd6eb4a..f1eaa55a8d18 100644 --- a/actions/websocket_actions.jsx +++ b/actions/websocket_actions.jsx @@ -22,9 +22,16 @@ import { markChannelAsRead, } from 'mattermost-redux/actions/channels'; import {setServerVersion} from 'mattermost-redux/actions/general'; +import { + getCustomEmojiForReaction, + getPosts, + getProfilesAndStatusesForPosts, + postDeleted, + receivedPost, + receivedPostsInChannel, +} from 'mattermost-redux/actions/posts'; import {clearErrors, logError} from 'mattermost-redux/actions/errors'; -import {getPosts, getProfilesAndStatusesForPosts, getCustomEmojiForReaction} from 'mattermost-redux/actions/posts'; import * as TeamActions from 'mattermost-redux/actions/teams'; import {getMe, getStatusesByIds, getProfilesByIds} from 'mattermost-redux/actions/users'; import {Client4} from 'mattermost-redux/client'; @@ -413,12 +420,8 @@ export function debouncePostEvent(func, wait) { if (!posts.hasOwnProperty(channelId)) { continue; } - dispatch({ - type: PostTypes.RECEIVED_POSTS, - data: {posts: posts[channelId]}, - channelId, - receivedNewPosts: true, - }); + + dispatch(receivedPostsInChannel(posts[channelId], channelId)); getProfilesAndStatusesForPosts(posts[channelId], dispatch, getState); } } @@ -465,10 +468,7 @@ function handleNewPostEventWrapped(msg) { export function handlePostEditEvent(msg) { // Store post const post = JSON.parse(msg.data.post); - dispatch({ - type: PostTypes.RECEIVED_POST, - data: post, - }); + dispatch(receivedPost(post)); getProfilesAndStatusesForPosts([post], dispatch, getState); const currentChannelId = getCurrentChannelId(getState()); @@ -483,7 +483,7 @@ export function handlePostEditEvent(msg) { function handlePostDeleteEvent(msg) { const post = JSON.parse(msg.data.post); - dispatch({type: PostTypes.POST_DELETED, data: post}); + dispatch(postDeleted(post)); } async function handleTeamAddedEvent(msg) { diff --git a/components/channel_header/channel_header.js b/components/channel_header/channel_header.js index 2ab476a7e089..56f9d212f8c4 100644 --- a/components/channel_header/channel_header.js +++ b/components/channel_header/channel_header.js @@ -173,7 +173,7 @@ export default class ChannelHeader extends React.PureComponent { } }; - getPinnedPosts = (e) => { + showPinnedPosts = (e) => { e.preventDefault(); if (this.props.rhsState === RHSStates.PIN) { this.props.actions.closeRightHandSide(); @@ -597,7 +597,7 @@ export default class ChannelHeader extends React.PureComponent { } buttonClass={'style--none ' + pinnedIconClass} buttonId={'channelHeaderPinButton'} - onClick={this.getPinnedPosts} + onClick={this.showPinnedPosts} tooltipKey={'pinnedPosts'} /> {this.state.showSearchBar ? ( diff --git a/components/create_post/create_post.jsx b/components/create_post/create_post.jsx index a1c7651007a2..dd5ec1c5b780 100644 --- a/components/create_post/create_post.jsx +++ b/components/create_post/create_post.jsx @@ -104,11 +104,6 @@ export default class CreatePost extends React.Component { fileInfos: PropTypes.array.isRequired, }).isRequired, - /** - * Data used adding reaction on +/- to recent post - */ - recentPostIdInChannel: PropTypes.string, - /** * Data used dispatching handleViewAction */ diff --git a/components/create_post/index.js b/components/create_post/index.js index 8d21d2e199f6..4b4df9af64d5 100644 --- a/components/create_post/index.js +++ b/components/create_post/index.js @@ -75,7 +75,6 @@ function makeMapStateToProps() { showTutorialTip: enableTutorial && tutorialStep === TutorialSteps.POST_POPOVER, messageInHistoryItem: getMessageInHistoryItem(state), draft, - recentPostIdInChannel, commentCountForPost: getCommentCountForPost(state, {post}), latestReplyablePostId, locale: getCurrentLocale(state), diff --git a/components/post_view/combined_user_activity_post/index.js b/components/post_view/combined_user_activity_post/index.js new file mode 100644 index 000000000000..3a626f219750 --- /dev/null +++ b/components/post_view/combined_user_activity_post/index.js @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {makeGenerateCombinedPost} from 'mattermost-redux/utils/post_list'; + +import Post from 'components/post_view/post'; + +export function makeMapStateToProps() { + const generateCombinedPost = makeGenerateCombinedPost(); + + return (state, ownProps) => { + return { + post: generateCombinedPost(state, ownProps.combinedId), + postId: ownProps.combinedId, + }; + }; +} + +// Note that this also passes through Post's mapStateToProps +export default connect(makeMapStateToProps)(Post); diff --git a/components/post_view/date_separator/date_separator.jsx b/components/post_view/date_separator/date_separator.jsx index 3b8716a78524..bfe51926121b 100644 --- a/components/post_view/date_separator/date_separator.jsx +++ b/components/post_view/date_separator/date_separator.jsx @@ -7,7 +7,10 @@ import {FormattedDate} from 'react-intl'; export default class DateSeparator extends React.PureComponent { static propTypes = { - date: PropTypes.instanceOf(Date), + date: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.instanceOf(Date), + ]).isRequired, timeZone: PropTypes.string, enableTimezone: PropTypes.bool, } diff --git a/components/post_view/floating_timestamp.jsx b/components/post_view/floating_timestamp/floating_timestamp.jsx similarity index 100% rename from components/post_view/floating_timestamp.jsx rename to components/post_view/floating_timestamp/floating_timestamp.jsx diff --git a/components/post_view/floating_timestamp/index.js b/components/post_view/floating_timestamp/index.js new file mode 100644 index 000000000000..bdda8336f9f2 --- /dev/null +++ b/components/post_view/floating_timestamp/index.js @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {getPost} from 'mattermost-redux/selectors/entities/posts'; +import * as PostListUtils from 'mattermost-redux/utils/post_list'; + +import FloatingTimestamp from './floating_timestamp'; + +function mapStateToProps(state, ownProps) { + let postId = ownProps.postId; + if (PostListUtils.isCombinedUserActivityPost(postId)) { + const combinedIds = PostListUtils.getPostIdsForCombinedUserActivityPost(postId); + + postId = combinedIds[combinedIds.length - 1]; + } + + const post = getPost(state, postId); + + return { + createAt: post ? post.create_at : 0, + }; +} + +export default connect(mapStateToProps)(FloatingTimestamp); diff --git a/components/post_view/index.js b/components/post_view/index.js index dcdf1c77b89d..4d9dc5abc683 100644 --- a/components/post_view/index.js +++ b/components/post_view/index.js @@ -5,15 +5,20 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import {withRouter} from 'react-router-dom'; -import {getPosts, getPostsAfter, getPostsBefore, getPostThread} from 'mattermost-redux/actions/posts'; import {getChannel} from 'mattermost-redux/selectors/entities/channels'; -import {makeGetPostsAroundPost, makeGetPostsInChannel} from 'mattermost-redux/selectors/entities/posts'; -import {getCurrentUserId, getUser} from 'mattermost-redux/selectors/entities/users'; +import { + getPostIdsInChannel, + makeGetPostIdsAroundPost, +} from 'mattermost-redux/selectors/entities/posts'; +import {getUser} from 'mattermost-redux/selectors/entities/users'; import {getTeamByName} from 'mattermost-redux/selectors/entities/teams'; +import {makePreparePostIdsForPostList} from 'mattermost-redux/utils/post_list'; -import {increasePostVisibility} from 'actions/post_actions.jsx'; -import {checkAndSetMobileView} from 'actions/views/channel'; -import {makePreparePostIdsForPostList} from 'selectors/posts'; +import { + checkAndSetMobileView, + increasePostVisibility, + loadInitialPosts, +} from 'actions/views/channel'; import {Constants} from 'utils/constants.jsx'; import PostList from './post_list.jsx'; @@ -44,18 +49,22 @@ const isChannelLoading = (params, channel, team, teammate) => { }; function makeMapStateToProps() { - const getPostsInChannel = makeGetPostsInChannel(); - const getPostsAroundPost = makeGetPostsAroundPost(); + const getPostIdsAroundPost = makeGetPostIdsAroundPost(); const preparePostIdsForPostList = makePreparePostIdsForPostList(); return function mapStateToProps(state, ownProps) { const postVisibility = state.views.channel.postVisibility[ownProps.channelId]; + const lastViewedAt = state.views.channel.lastChannelViewTime[ownProps.channelId]; - let posts; + let postIds; if (ownProps.focusedPostId) { - posts = getPostsAroundPost(state, ownProps.focusedPostId, ownProps.channelId); + postIds = getPostIdsAroundPost(state, ownProps.focusedPostId, ownProps.channelId); } else { - posts = getPostsInChannel(state, ownProps.channelId, postVisibility); + postIds = getPostIdsInChannel(state, ownProps.channelId); + } + + if (postIds) { + postIds = preparePostIdsForPostList(state, {postIds, lastViewedAt, indicateNewMessages: true}); } const channel = getChannel(state, ownProps.channelId); @@ -66,19 +75,12 @@ function makeMapStateToProps() { } const channelLoading = isChannelLoading(ownProps.match.params, channel, team, teammate); - const lastViewedAt = state.views.channel.lastChannelViewTime[ownProps.channelId]; - const {postIds, postsObjById} = preparePostIdsForPostList(state, {posts, lastViewedAt, indicateNewMessages: true}); return { channel, lastViewedAt, - posts, - postsObjById, postVisibility, postListIds: postIds, - loadingPosts: state.views.channel.loadingPosts[ownProps.channelId], - focusedPostId: ownProps.focusedPostId, - currentUserId: getCurrentUserId(state), channelLoading, }; }; @@ -87,10 +89,7 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ - getPosts, - getPostsBefore, - getPostsAfter, - getPostThread, + loadInitialPosts, increasePostVisibility, checkAndSetMobileView, }, dispatch), diff --git a/components/post_view/new_messages_below/index.js b/components/post_view/new_messages_below/index.js new file mode 100644 index 000000000000..82c53b39746b --- /dev/null +++ b/components/post_view/new_messages_below/index.js @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {createSelector} from 'reselect'; + +import {Posts} from 'mattermost-redux/constants'; +import {getAllPosts} from 'mattermost-redux/selectors/entities/posts'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; + +import NewMessagesBelow from './new_messages_below'; + +export function makeCountUnreadsBelow() { + return createSelector( + getAllPosts, + getCurrentUserId, + (state, postIds) => postIds, + (state, postIds, lastViewedBottom) => lastViewedBottom, + (allPosts, currentUserId, postIds, lastViewedBottom) => { + if (!postIds) { + return 0; + } + + // Count the number of new posts made by other users that haven't been deleted + return postIds.map((id) => allPosts[id]).filter((post) => { + return post && + post.user_id !== currentUserId && + post.state !== Posts.POST_DELETED && + post.create_at > lastViewedBottom; + }).length; + } + ); +} + +function makeMapStateToProps() { + const countUnreadsBelow = makeCountUnreadsBelow(); + + return (state, ownProps) => { + return { + newMessages: countUnreadsBelow(state, ownProps.postIds, ownProps.lastViewedBottom), + }; + }; +} + +export default connect(makeMapStateToProps)(NewMessagesBelow); diff --git a/components/post_view/new_messages_below/index.test.js b/components/post_view/new_messages_below/index.test.js new file mode 100644 index 000000000000..6bf4717efbb2 --- /dev/null +++ b/components/post_view/new_messages_below/index.test.js @@ -0,0 +1,81 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Posts} from 'mattermost-redux/constants'; + +import {makeCountUnreadsBelow} from './index'; + +describe('makeCountUnreadsBelow', () => { + test('should only count new posts', () => { + const countUnreadsBelow = makeCountUnreadsBelow(); + + const state = { + entities: { + posts: { + posts: { + post1: {create_at: 1000}, + post2: {create_at: 2000}, + post3: {create_at: 3000}, + post4: {create_at: 4000}, + }, + }, + users: { + currentUserId: 'user1', + }, + }, + }; + const postIds = ['post1', 'post2', 'post3', 'post4']; + + expect(countUnreadsBelow(state, postIds, 1500)).toBe(3); + expect(countUnreadsBelow(state, postIds, 2500)).toBe(2); + expect(countUnreadsBelow(state, postIds, 4000)).toBe(0); + }); + + test('should not count deleted posts', () => { + const countUnreadsBelow = makeCountUnreadsBelow(); + + const state = { + entities: { + posts: { + posts: { + post1: {create_at: 1000, state: Posts.POST_DELETED}, + post2: {create_at: 2000}, + post3: {create_at: 3000, state: Posts.POST_DELETED}, + post4: {create_at: 4000}, + }, + }, + users: { + currentUserId: 'user1', + }, + }, + }; + const postIds = ['post1', 'post2', 'post3', 'post4']; + + expect(countUnreadsBelow(state, postIds, 500)).toBe(2); + expect(countUnreadsBelow(state, postIds, 2500)).toBe(1); + }); + + test('should not count posts made by the current user', () => { + const countUnreadsBelow = makeCountUnreadsBelow(); + + const state = { + entities: { + posts: { + posts: { + post1: {create_at: 1000, user_id: 'user1'}, + post2: {create_at: 2000}, + post3: {create_at: 3000}, + post4: {create_at: 4000, user_id: 'user1'}, + }, + }, + users: { + currentUserId: 'user1', + }, + }, + }; + const postIds = ['post1', 'post2', 'post3', 'post4']; + + expect(countUnreadsBelow(state, postIds, 500)).toBe(2); + expect(countUnreadsBelow(state, postIds, 2500)).toBe(1); + }); +}); diff --git a/components/post_view/new_message_indicator.jsx b/components/post_view/new_messages_below/new_messages_below.jsx similarity index 92% rename from components/post_view/new_message_indicator.jsx rename to components/post_view/new_messages_below/new_messages_below.jsx index dd722b429b47..0d40ec620e9d 100644 --- a/components/post_view/new_message_indicator.jsx +++ b/components/post_view/new_messages_below/new_messages_below.jsx @@ -7,8 +7,9 @@ import {FormattedMessage} from 'react-intl'; import UnreadBelowIcon from 'components/svg/unread_below_icon'; -export default class NewMessageIndicator extends React.PureComponent { +export default class NewMessagesBelow extends React.PureComponent { static propTypes = { + atBottom: PropTypes.bool, onClick: PropTypes.func.isRequired, newMessages: PropTypes.number, }; @@ -26,7 +27,7 @@ export default class NewMessageIndicator extends React.PureComponent { } UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase - if (nextProps.newMessages > 0) { + if (nextProps.newMessages > 0 && !nextProps.atBottom) { this.setState({rendered: true}, () => { this.setState({visible: true}); }); diff --git a/components/post_view/post/index.js b/components/post_view/post/index.js index 6862a4536ef5..29e2846e67db 100644 --- a/components/post_view/post/index.js +++ b/components/post_view/post/index.js @@ -3,28 +3,72 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {getPost} from 'mattermost-redux/selectors/entities/posts'; +import {createSelector} from 'reselect'; + +import {Posts} from 'mattermost-redux/constants'; +import {getPost, makeIsPostCommentMention} from 'mattermost-redux/selectors/entities/posts'; import {get} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {isPostEphemeral, isSystemMessage} from 'mattermost-redux/utils/post_utils'; import {selectPost} from 'actions/views/rhs'; import {Preferences} from 'utils/constants.jsx'; import Post from './post.jsx'; -function mapStateToProps(state, ownProps) { - const detailedPost = ownProps.post || {}; +export function makeGetReplyCount() { + return createSelector( + (state) => state.entities.posts.posts, + (state, post) => state.entities.posts.postsInThread[post.root_id || post.id], + (allPosts, postIds) => { + if (!postIds) { + return 0; + } - return { - post: getPost(state, detailedPost.id), - currentUserId: getCurrentUserId(state), - isFirstReply: Boolean(detailedPost.isFirstReply && detailedPost.commentedOnPost), - consecutivePostByUser: detailedPost.consecutivePostByUser, - previousPostIsComment: detailedPost.previousPostIsComment, - replyCount: detailedPost.replyCount, - isCommentMention: detailedPost.isCommentMention, - center: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_CENTERED, - compactDisplay: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT, + // Count the number of non-ephemeral posts in the thread + return postIds.map((id) => allPosts[id]).filter((post) => post && !isPostEphemeral(post)).length; + } + ); +} + +function makeMapStateToProps() { + const getReplyCount = makeGetReplyCount(); + const isPostCommentMention = makeIsPostCommentMention(); + + return (state, ownProps) => { + const post = ownProps.post || getPost(state, ownProps.postId); + + let previousPost = null; + if (ownProps.previousPostId) { + previousPost = getPost(state, ownProps.previousPostId); + } + + let isFirstReply = false; + let consecutivePostByUser = false; + let previousPostIsComment = false; + + if (previousPost) { + isFirstReply = post.root_id ? (post.root_id !== previousPost.id && post.root_id !== previousPost.root_id) : false; + + consecutivePostByUser = post.user_id === previousPost.user_id && // The post is by the same user + post.create_at - previousPost.create_at <= Posts.POST_COLLAPSE_TIMEOUT && // And was within a short time period + !(post.props && post.props.from_webhook) && !(previousPost.props && previousPost.props.from_webhook) && // And neither is from a webhook + !isSystemMessage(post) && !isSystemMessage(previousPost); // And neither is a system message + + previousPostIsComment = Boolean(previousPost.root_id); + } + + return { + post, + currentUserId: getCurrentUserId(state), + isFirstReply, + consecutivePostByUser, + previousPostIsComment, + replyCount: getReplyCount(state, post), + isCommentMention: isPostCommentMention(state, post.id), + center: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_CENTERED, + compactDisplay: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT, + }; }; } @@ -36,4 +80,4 @@ function mapDispatchToProps(dispatch) { }; } -export default connect(mapStateToProps, mapDispatchToProps)(Post); +export default connect(makeMapStateToProps, mapDispatchToProps)(Post); diff --git a/components/post_view/post/index.test.js b/components/post_view/post/index.test.js new file mode 100644 index 000000000000..4de15f966a47 --- /dev/null +++ b/components/post_view/post/index.test.js @@ -0,0 +1,92 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Posts} from 'mattermost-redux/constants'; + +import {makeGetReplyCount} from './index'; + +describe('makeGetReplyCount', () => { + test('should return the number of comments when called on a root post', () => { + const getReplyCount = makeGetReplyCount(); + + const state = { + entities: { + posts: { + posts: { + post1: {id: 'post1'}, + post2: {id: 'post2', root_id: 'post1'}, + post3: {id: 'post3', root_id: 'post1'}, + }, + postsInThread: { + post1: ['post2', 'post3'], + }, + }, + }, + }; + const post = state.entities.posts.posts.post1; + + expect(getReplyCount(state, post)).toBe(2); + }); + + test('should return the number of comments when called on a comment', () => { + const getReplyCount = makeGetReplyCount(); + + const state = { + entities: { + posts: { + posts: { + post1: {id: 'post1'}, + post2: {id: 'post2', root_id: 'post1'}, + post3: {id: 'post3', root_id: 'post1'}, + }, + postsInThread: { + post1: ['post2', 'post3'], + }, + }, + }, + }; + const post = state.entities.posts.posts.post3; + + expect(getReplyCount(state, post)).toBe(2); + }); + + test('should return 0 when called on a post without comments', () => { + const getReplyCount = makeGetReplyCount(); + + const state = { + entities: { + posts: { + posts: { + post1: {id: 'post1'}, + }, + postsInThread: {}, + }, + }, + }; + const post = state.entities.posts.posts.post1; + + expect(getReplyCount(state, post)).toBe(0); + }); + + test('should not count ephemeral comments', () => { + const getReplyCount = makeGetReplyCount(); + + const state = { + entities: { + posts: { + posts: { + post1: {id: 'post1'}, + post2: {id: 'post2', root_id: 'post1', type: Posts.POST_TYPES.EPHEMERAL}, + post3: {id: 'post3', root_id: 'post1'}, + }, + postsInThread: { + post1: ['post2', 'post3'], + }, + }, + }, + }; + const post = state.entities.posts.posts.post1; + + expect(getReplyCount(state, post)).toBe(1); + }); +}); diff --git a/components/post_view/post_list.jsx b/components/post_view/post_list.jsx index b7c7026678c4..0d7aa04c47fd 100644 --- a/components/post_view/post_list.jsx +++ b/components/post_view/post_list.jsx @@ -6,18 +6,18 @@ import React from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import {DynamicSizeList} from 'react-window'; +import LoadingScreen from 'components/loading_screen.jsx'; + import Constants, {PostListRowListIds} from 'utils/constants.jsx'; import DelayedAction from 'utils/delayed_action.jsx'; +import {getLastPostId} from 'utils/post_utils.jsx'; import * as Utils from 'utils/utils.jsx'; -import {getClosestValidPostIndex} from 'utils/post_utils.jsx'; -import LoadingScreen from 'components/loading_screen.jsx'; -import NewMessageIndicator from './new_message_indicator.jsx'; -import FloatingTimestamp from './floating_timestamp.jsx'; +import FloatingTimestamp from './floating_timestamp'; +import NewMessagesBelow from './new_messages_below'; import PostListRow from './post_list_row'; -import ScrollToBottomArrows from './scroll_to_bottom_arrows.jsx'; +import ScrollToBottomArrows from './scroll_to_bottom_arrows'; -const POSTS_PER_PAGE = Constants.POST_CHUNK_SIZE / 2; const MAX_NUMBER_OF_AUTO_RETRIES = 3; const MAX_EXTRA_PAGES_LOADED = 10; @@ -28,23 +28,12 @@ const HEIGHT_TRIGGER_FOR_MORE_POSTS = window.HEIGHT_TRIGGER_FOR_MORE_POSTS || 10 export default class PostList extends React.PureComponent { static propTypes = { - /** - * Array of posts in the channel, ordered from oldest to newest - */ - posts: PropTypes.array, - /** * Array of Ids in the channel including date separators, new message indicator, more messages loader, * manual load messages trigger and postId in the order of newest to oldest for populating virtual list rows */ postListIds: PropTypes.array, - /** - * Object of posts with post.id as key. This is used for passing down post as we need extra metadata made in - * getPostsAroundPost and getPostsInChannel. The metadata includes `consecutivePostByUser`, `isFirstReply` etc - */ - postsObjById: PropTypes.object, - /** * The number of posts that should be rendered */ @@ -60,11 +49,6 @@ export default class PostList extends React.PureComponent { */ lastViewedAt: PropTypes.number, - /** - * The user id of the logged in user - */ - currentUserId: PropTypes.string, - /** * Set to focus this post */ @@ -78,25 +62,7 @@ export default class PostList extends React.PureComponent { actions: PropTypes.shape({ - /** - * Function to get posts in the channel - */ - getPosts: PropTypes.func.isRequired, - - /** - * Function to get posts in the channel older than the focused post - */ - getPostsBefore: PropTypes.func.isRequired, - - /** - * Function to get posts in the channel newer than the focused post - */ - getPostsAfter: PropTypes.func.isRequired, - - /** - * Function to get the post thread for the focused post - */ - getPostThread: PropTypes.func.isRequired, + loadInitialPosts: PropTypes.func.isRequired, /** * Function to increase the number of posts being rendered @@ -120,16 +86,14 @@ export default class PostList extends React.PureComponent { const isMobile = Utils.isMobile(); this.state = { atEnd: false, - loadingFirstSetOfPosts: !props.posts || props.channelLoading, + loadingFirstSetOfPosts: Boolean(!props.postListIds || props.channelLoading), isScrolling: false, lastViewed: props.lastViewedAt, autoRetryEnable: true, isMobile, atBottom: true, - unViewedCount: 0, postListIds: [channelIntroMessage], - postsObjById: {channelIntroMessage}, - floatingTimestampDate: 0, + topPostId: '', postMenuOpened: false, dynamicListStyle: { willChange: 'transform', @@ -153,22 +117,6 @@ export default class PostList extends React.PureComponent { } componentDidUpdate(prevProps) { - const prevPosts = prevProps.posts; - const {posts} = this.props; - if (prevPosts && posts && prevPosts.length && posts.length) { - const presentLastPost = posts[0]; - const previousLastPost = prevPosts[0]; - const isNewPostByCurrentUser = previousLastPost.id !== presentLastPost.id && presentLastPost.id === presentLastPost.pending_post_id; - const isNewPostNotReply = !presentLastPost.parent_id; - if (isNewPostByCurrentUser && isNewPostNotReply) { - this.scrollToBottom(); - } - const hasNewPosts = prevPosts.length > 0 && posts.length > 0 && previousLastPost.id !== presentLastPost.id; - if (!this.state.atBottom && hasNewPosts) { - this.setUnreadsBelow(posts, this.props.currentUserId); - } - } - if (prevProps.channelLoading && !this.props.channelLoading) { this.loadPosts(this.props.channel.id, this.props.focusedPostId); } @@ -180,26 +128,22 @@ export default class PostList extends React.PureComponent { } static getDerivedStateFromProps(props, state) { - const {postListIds} = props; - if (postListIds) { - let newPostListIds; - if (state.atEnd) { - return { - postListIds: [...props.postListIds, PostListRowListIds.CHANNEL_INTRO_MESSAGE], - }; - } - if (props.postVisibility >= Constants.MAX_POST_VISIBILITY) { - newPostListIds = [...postListIds, PostListRowListIds.MAX_MESSAGES_LOADED]; - } else if (state.autoRetryEnable) { - newPostListIds = [...postListIds, PostListRowListIds.MORE_MESSAGES_LOADER]; - } else { - newPostListIds = [...postListIds, PostListRowListIds.MANUAL_TRIGGER_LOAD_MESSAGES]; - } - return { - postListIds: newPostListIds, - }; + const postListIds = props.postListIds || []; + let newPostListIds; + + if (state.atEnd) { + newPostListIds = [...postListIds, PostListRowListIds.CHANNEL_INTRO_MESSAGE]; + } else if (props.postVisibility >= Constants.MAX_POST_VISIBILITY) { + newPostListIds = [...postListIds, PostListRowListIds.MAX_MESSAGES_LOADED]; + } else if (state.autoRetryEnable) { + newPostListIds = [...postListIds, PostListRowListIds.MORE_MESSAGES_LOADER]; + } else { + newPostListIds = [...postListIds, PostListRowListIds.MANUAL_TRIGGER_LOAD_MESSAGES]; } - return null; + + return { + postListIds: newPostListIds, + }; } handleWindowResize = () => { @@ -223,54 +167,32 @@ export default class PostList extends React.PureComponent { } } - setUnreadsBelow = (posts, currentUserId) => { - const unViewedCount = posts.reduce((count, post) => { - if (post.create_at > this.state.lastViewed && - post.user_id !== currentUserId && - post.state !== Constants.POST_DELETED) { - return count + 1; - } - return count; - }, 0); - if (this.mounted) { - this.setState({unViewedCount}); - } - }; - loadPosts = async (channelId, focusedPostId) => { if (!channelId) { return; } - let posts; - if (focusedPostId) { - const getPostThreadAsync = this.props.actions.getPostThread(focusedPostId, false); - const getPostsBeforeAsync = this.props.actions.getPostsBefore(channelId, focusedPostId, 0, POSTS_PER_PAGE); - const getPostsAfterAsync = this.props.actions.getPostsAfter(channelId, focusedPostId, 0, POSTS_PER_PAGE); - - const result = await getPostsBeforeAsync; - posts = result.data; - await getPostsAfterAsync; - await getPostThreadAsync; - } else { - const result = await this.props.actions.getPosts(channelId, 0, POSTS_PER_PAGE); - posts = result.data; - } + const {hasMoreBefore} = await this.props.actions.loadInitialPosts(channelId, focusedPostId); if (this.mounted) { - const atEnd = Boolean(posts && posts.order.length < POSTS_PER_PAGE); - const newState = { + this.setState({ loadingFirstSetOfPosts: false, - atEnd, - }; - - this.setState(newState); + atEnd: !hasMoreBefore, + }); } } loadMorePosts = async () => { + const oldestPostId = this.getOldestVisiblePostId(); + + if (!oldestPostId) { + // loadMorePosts shouldn't be called if we don't already have posts + return; + } + this.loadingMorePosts = true; - const {moreToLoad, error} = await this.props.actions.increasePostVisibility(this.props.channel.id, this.props.focusedPostId); + + const {moreToLoad, error} = await this.props.actions.increasePostVisibility(this.props.channel.id, oldestPostId); if (error) { if (this.autoRetriesCount < MAX_NUMBER_OF_AUTO_RETRIES) { this.autoRetriesCount++; @@ -280,21 +202,24 @@ export default class PostList extends React.PureComponent { } } else { this.loadingMorePosts = false; - if (this.mounted && this.props.posts) { - const atEnd = !moreToLoad; - const newState = { - atEnd, - autoRetryEnable: true, - }; - this.setState(newState); + if (this.mounted && this.props.postListIds) { + this.setState({ + atEnd: !moreToLoad, + autoRetryEnable: true, + }); } + if (!this.state.autoRetryEnable) { this.autoRetriesCount = 0; } } }; + getOldestVisiblePostId = () => { + return getLastPostId(this.state.postListIds); + } + togglePostMenu = (opened) => { const dynamicListStyle = this.state.dynamicListStyle; if (this.state.isMobile) { @@ -307,14 +232,17 @@ export default class PostList extends React.PureComponent { }); }; - renderRow = ({itemId, style}) => { + renderRow = ({data, itemId, style}) => { + const index = data.indexOf(itemId); + const previousItemId = (index !== -1 && index < data.length - 1) ? data[index + 1] : ''; + return (
@@ -341,6 +269,7 @@ export default class PostList extends React.PureComponent { isScrolling: true, }); } + if (this.scrollStopAction) { this.scrollStopAction.fireAfter(Constants.SCROLL_DELAY); } @@ -359,17 +288,14 @@ export default class PostList extends React.PureComponent { if (!this.state.isMobile) { return; } - if (this.props.posts) { - const postIndex = getClosestValidPostIndex(this.state.postListIds, visibleTopItem); - const postId = this.state.postListIds[postIndex]; - const topPostInView = this.props.posts.find((post) => post.id === postId); - const floatingTimestampDate = topPostInView ? Utils.getDateForUnixTicks(topPostInView.create_at) : 0; - if (floatingTimestampDate !== this.state.floatingTimestampDate) { - this.setState({ - floatingTimestampDate, - }); - } + + if (!this.props.postListIds) { + return; } + + this.setState({ + topPostId: getLastPostId(this.props.postListIds.slice(visibleTopItem)), + }); } checkBottom = (visibleStartIndex) => { @@ -378,7 +304,6 @@ export default class PostList extends React.PureComponent { this.setState({ atBottom: true, lastViewed: new Date().getTime(), - unViewedCount: 0, }); } } else if (this.state.atBottom) { @@ -411,7 +336,7 @@ export default class PostList extends React.PureComponent { ); if (newMessagesSeparatorIndex > 0) { - const topMostPostIndex = getClosestValidPostIndex(this.state.postListIds, this.state.postListIds.length); + const topMostPostIndex = this.state.postListIds.indexOf(getLastPostId(this.state.postListIds)); if (newMessagesSeparatorIndex === topMostPostIndex + 1) { this.loadMorePosts(); return { @@ -463,7 +388,7 @@ export default class PostList extends React.PureComponent { render() { const channel = this.props.channel; - if (this.state.loadingFirstSetOfPosts || channel == null) { + if (this.state.loadingFirstSetOfPosts) { return (
+ ); + } + return (
{this.state.isMobile && ( @@ -483,7 +420,7 @@ export default class PostList extends React.PureComponent { )} - + {newMessagesBelow}
{ - const postsArray = []; - const Ids = []; - const createAtValue = 12346; - for (var i = 1; i <= 30; i++) { - const postCreatedAt = createAtValue + i; - postsArray.push({ - id: `${postCreatedAt}`, - message: 'test', - create_at: postCreatedAt, - }); - Ids.push(`${postCreatedAt}`); - } +import {DATE_LINE} from 'mattermost-redux/utils/post_list'; - return { - postsArray, - Ids, - }; -}; - -describe('components/post_view/post_list', () => { - const posts = [{ - id: 'postId', - message: 'test', - create_at: 12345, - }]; - - const actions = { - getPosts: jest.fn().mockResolvedValue({data: {order: [], posts: {}}}), - getPostsBefore: jest.fn().mockResolvedValue({data: {order: [], posts: {}}}), - getPostsAfter: jest.fn().mockResolvedValue({data: {order: [], posts: {}}}), - getPostThread: jest.fn().mockResolvedValue({data: {order: [], posts: {}}}), - increasePostVisibility: jest.fn().mockResolvedValue({moreToLoad: true}), - checkAndSetMobileView: jest.fn(), - }; +import LoadingScreen from 'components/loading_screen'; +import {PostListRowListIds} from 'utils/constants'; +import NewMessagesBelow from './new_messages_below'; +import PostList from './post_list'; +import PostListRow from './post_list_row'; + +describe('PostList', () => { const baseProps = { - posts, - postListIds: ['postId'], - postsObjById: { - postId: posts[0], - }, - postVisibility: 30, - channel: { - id: 'channelId', + channel: {id: 'channel'}, + focusedPostId: '', + lastViewedAt: 0, + postListIds: [ + 'post1', + 'post2', + 'post3', + DATE_LINE + 1551711600000, + ], + postVisibility: 10, + actions: { + checkAndSetMobileView: jest.fn(), + increasePostVisibility: jest.fn(), + loadInitialPosts: jest.fn(() => ({posts: {posts: {}, order: []}, hasMoreBefore: false})), }, - lastViewedAt: 12344, - currentUserId: 'currentUserId', - channelLoading: false, - actions, }; - test('should return index of loader when all are unread messages in the view and call increasePostVisibility action', () => { - const {Ids, postsArray} = returnDummyPostsAndIds(); - const postListIds = [...Ids, PostListRowListIds.START_OF_NEW_MESSAGES]; + test('should render loading screen while loading posts', () => { const props = { ...baseProps, - posts: postsArray, - lastViewedAt: 12345, - postListIds, + postListIds: null, }; + const wrapper = shallow(); - const initScrollToIndex = wrapper.instance().initScrollToIndex(); - expect(initScrollToIndex).toEqual({index: 31, position: 'start'}); //Loader will be at pos 31 - expect(actions.increasePostVisibility).toHaveBeenCalledTimes(1); + + expect(wrapper.state('loadingFirstSetOfPosts')).toBe(true); + expect(wrapper.find(LoadingScreen).exists()).toBe(true); + + wrapper.setState({loadingFirstSetOfPosts: false}); + + expect(wrapper.find(LoadingScreen).exists()).toBe(false); }); - test('should set the state of atEnd to true when increasePostVisibility returns moreToLoad false', async () => { - const increasePostVisibilityMock = jest.fn().mockResolvedValue({moreToLoad: false}); - const props = { - ...baseProps, - actions: { - ...baseProps.actions, - increasePostVisibility: increasePostVisibilityMock, - }, - }; - const wrapper = mountWithIntl(); - await wrapper.instance().loadMorePosts(); - expect(increasePostVisibilityMock).toHaveBeenCalledTimes(1); - wrapper.update(); - expect(wrapper.state('atEnd')).toEqual(true); + describe('renderRow', () => { + const postListIds = ['a', 'b', 'c', 'd']; + + test('should get previous item ID correctly for oldest row', () => { + const wrapper = shallow(); + const row = shallow(wrapper.instance().renderRow({ + data: postListIds, + itemId: 'd', + })); + + expect(row.find(PostListRow).prop('previousListId')).toEqual(''); + }); + + test('should get previous item ID correctly for other rows', () => { + const wrapper = shallow(); + const row = shallow(wrapper.instance().renderRow({ + data: postListIds, + itemId: 'b', + })); + + expect(row.find(PostListRow).prop('previousListId')).toEqual('c'); + }); + + test('should highlight the focused post', () => { + const props = { + ...baseProps, + focusedPostId: 'b', + }; + + const wrapper = shallow(); + + let row = shallow(wrapper.instance().renderRow({ + data: postListIds, + itemId: 'c', + })); + expect(row.find(PostListRow).prop('shouldHighlight')).toEqual(false); + + row = shallow(wrapper.instance().renderRow({ + data: postListIds, + itemId: 'b', + })); + expect(row.find(PostListRow).prop('shouldHighlight')).toEqual(true); + }); + }); + + describe('new messages below', () => { + test('should mount outside of permalink view', () => { + const wrapper = shallow(); + wrapper.setState({loadingFirstSetOfPosts: false}); + + expect(wrapper.find(NewMessagesBelow).exists()).toBe(true); + }); + + test('should not mount when in permalink view', () => { + const props = { + ...baseProps, + focusedPostId: '1234', + }; + + const wrapper = shallow(); + wrapper.setState({loadingFirstSetOfPosts: false}); + + expect(wrapper.find(NewMessagesBelow).exists()).toBe(false); + }); + }); + + describe('initScrollToIndex', () => { + test('should return index of start of new messages and call increasePostVisibility when all posts are unread', () => { + const postListIds = []; + for (let i = 0; i < 30; i++) { + postListIds.push(`post${i}`); + } + postListIds.push(PostListRowListIds.START_OF_NEW_MESSAGES); + + const props = { + ...baseProps, + postListIds, + }; + + const wrapper = shallow(); + const initScrollToIndex = wrapper.instance().initScrollToIndex(); + + expect(initScrollToIndex).toEqual({index: 31, position: 'start'}); + + expect(baseProps.actions.increasePostVisibility).toHaveBeenCalledTimes(1); + }); + }); + + describe('loadMorePosts', () => { + test('should set state.atEnd to true after loading all posts in the channel', async () => { + baseProps.actions.increasePostVisibility.mockResolvedValue({moreToLoad: false}); + + const wrapper = shallow(); + + await wrapper.instance().loadMorePosts(); + + expect(baseProps.actions.increasePostVisibility).toHaveBeenCalledTimes(1); + expect(baseProps.actions.increasePostVisibility).toHaveBeenCalledWith(baseProps.channel.id, 'post3'); + + wrapper.update(); + + expect(wrapper.state('atEnd')).toEqual(true); + }); }); }); diff --git a/components/post_view/post_list_row/__snapshots__/post_list_row.test.jsx.snap b/components/post_view/post_list_row/__snapshots__/post_list_row.test.jsx.snap index f2b7dcfb324b..0bff8b97ca32 100644 --- a/components/post_view/post_list_row/__snapshots__/post_list_row.test.jsx.snap +++ b/components/post_view/post_list_row/__snapshots__/post_list_row.test.jsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`components/post_view/post_list_row should render channel header 1`] = ` +exports[`components/post_view/post_list_row should render channel intro message 1`] = ` `; +exports[`components/post_view/post_list_row should render combined post 1`] = ` + +`; + exports[`components/post_view/post_list_row should render date line 1`] = ` `; @@ -53,20 +61,14 @@ exports[`components/post_view/post_list_row should render more messages loading exports[`components/post_view/post_list_row should render new messages line 1`] = ` `; -exports[`components/post_view/post_list_row should render null for unknown type of component 1`] = `""`; - exports[`components/post_view/post_list_row should render post 1`] = ` `; diff --git a/components/post_view/post_list_row/post_list_row.jsx b/components/post_view/post_list_row/post_list_row.jsx index 653df345a158..b440769e83dc 100644 --- a/components/post_view/post_list_row/post_list_row.jsx +++ b/components/post_view/post_list_row/post_list_row.jsx @@ -5,16 +5,19 @@ import PropTypes from 'prop-types'; import React from 'react'; import {FormattedMessage} from 'react-intl'; +import * as PostListUtils from 'mattermost-redux/utils/post_list'; + +import CombinedUserActivityPost from 'components/post_view/combined_user_activity_post'; import Post from 'components/post_view/post'; import DateSeparator from 'components/post_view/date_separator'; import NewMessageSeparator from 'components/post_view/new_message_separator/new_message_separator'; -import CreateChannelIntroMessage from 'components/post_view/channel_intro_message/'; +import ChannelIntroMessage from 'components/post_view/channel_intro_message/'; import {PostListRowListIds} from 'utils/constants'; export default class PostListRow extends React.PureComponent { static propTypes = { - post: PropTypes.object, listId: PropTypes.string.isRequired, + previousListId: PropTypes.string, channel: PropTypes.object, fullWidth: PropTypes.bool, shouldHighlight: PropTypes.bool, @@ -23,30 +26,20 @@ export default class PostListRow extends React.PureComponent { } render() { - const {post, listId} = this.props; - if (post) { - return ( - - ); - } + const {listId, previousListId} = this.props; + + if (PostListUtils.isDateLine(listId)) { + const date = PostListUtils.getDateForDateLine(listId); - if (listId.indexOf(PostListRowListIds.DATE_LINE) === 0) { - const postDay = new Date(listId.split(PostListRowListIds.DATE_LINE)[1]); return ( ); } - if (listId === PostListRowListIds.START_OF_NEW_MESSAGES) { + if (PostListUtils.isStartOfNewMessages(listId)) { return ( ); @@ -54,7 +47,7 @@ export default class PostListRow extends React.PureComponent { if (listId === PostListRowListIds.CHANNEL_INTRO_MESSAGE) { return ( - @@ -89,6 +82,26 @@ export default class PostListRow extends React.PureComponent { ); } - return null; + const postProps = { + previousPostId: previousListId, + shouldHighlight: this.props.shouldHighlight, + togglePostMenu: this.props.togglePostMenu, + }; + + if (PostListUtils.isCombinedUserActivityPost(listId)) { + return ( + + ); + } + + return ( + + ); } } diff --git a/components/post_view/post_list_row/post_list_row.test.jsx b/components/post_view/post_list_row/post_list_row.test.jsx index 01f402d975a3..f05b41f33905 100644 --- a/components/post_view/post_list_row/post_list_row.test.jsx +++ b/components/post_view/post_list_row/post_list_row.test.jsx @@ -3,23 +3,20 @@ import React from 'react'; +import * as PostListUtils from 'mattermost-redux/utils/post_list'; + +import CombinedUserActivityPost from 'components/post_view/combined_user_activity_post'; +import Post from 'components/post_view/post'; +import DateSeparator from 'components/post_view/date_separator'; +import NewMessageSeparator from 'components/post_view/new_message_separator/new_message_separator'; +import ChannelIntroMessage from 'components/post_view/channel_intro_message/'; + import {shallowWithIntl} from 'tests/helpers/intl-test-helper.jsx'; import {PostListRowListIds} from 'utils/constants'; import PostListRow from './post_list_row.jsx'; describe('components/post_view/post_list_row', () => { - test('should render null for unknown type of component', () => { - const listId = 'unknown'; - const props = { - listId, - }; - const wrapper = shallowWithIntl( - - ); - expect(wrapper).toMatchSnapshot(); - }); - test('should render more messages loading indicator', () => { const listId = PostListRowListIds.MORE_MESSAGES_LOADER; const props = { @@ -46,7 +43,7 @@ describe('components/post_view/post_list_row', () => { expect(loadMorePosts).toHaveBeenCalledTimes(1); }); - test('should render channel header', () => { + test('should render channel intro message', () => { const listId = PostListRowListIds.CHANNEL_INTRO_MESSAGE; const props = { channel: { @@ -60,6 +57,7 @@ describe('components/post_view/post_list_row', () => { ); expect(wrapper).toMatchSnapshot(); + expect(wrapper.find(ChannelIntroMessage).exists()).toBe(true); }); test('should render new messages line', () => { @@ -71,10 +69,11 @@ describe('components/post_view/post_list_row', () => { ); expect(wrapper).toMatchSnapshot(); + expect(wrapper.find(NewMessageSeparator).exists()).toBe(true); }); test('should render date line', () => { - const listId = `${PostListRowListIds.DATE_LINE}-Thu Mar 21 2019 00:00:00 GMT+0530 (India Standard Time)`; + const listId = `${PostListRowListIds.DATE_LINE}1553106600000`; const props = { listId, }; @@ -82,19 +81,32 @@ describe('components/post_view/post_list_row', () => { ); expect(wrapper).toMatchSnapshot(); + expect(wrapper.find(DateSeparator).exists()).toBe(true); + }); + + test('should render combined post', () => { + const props = { + shouldHighlight: false, + listId: `${PostListUtils.COMBINED_USER_ACTIVITY}-1234-5678`, + previousListId: 'abcd', + }; + const wrapper = shallowWithIntl( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find(CombinedUserActivityPost).exists()).toBe(true); }); test('should render post', () => { const props = { - post: { - id: '1234', - }, shouldHighlight: false, listId: '1234', + previousListId: 'abcd', }; const wrapper = shallowWithIntl( ); expect(wrapper).toMatchSnapshot(); + expect(wrapper.find(Post).exists()).toBe(true); }); }); diff --git a/components/rhs_thread/rhs_thread.jsx b/components/rhs_thread/rhs_thread.jsx index 6d458e58acaa..ff8e35c142a5 100644 --- a/components/rhs_thread/rhs_thread.jsx +++ b/components/rhs_thread/rhs_thread.jsx @@ -14,7 +14,7 @@ import * as Utils from 'utils/utils.jsx'; import * as UserAgent from 'utils/user_agent.jsx'; import CreateComment from 'components/create_comment'; import DateSeparator from 'components/post_view/date_separator'; -import FloatingTimestamp from 'components/post_view/floating_timestamp.jsx'; +import FloatingTimestamp from 'components/post_view/floating_timestamp'; import RhsComment from 'components/rhs_comment'; import RhsHeaderPost from 'components/rhs_header_post'; import RhsRootPost from 'components/rhs_root_post'; @@ -69,7 +69,7 @@ export default class RhsThread extends React.Component { windowWidth: Utils.windowWidth(), windowHeight: Utils.windowHeight(), isScrolling: false, - topRhsPostCreateAt: 0, + topRhsPostId: 0, openTime, }; } @@ -131,7 +131,7 @@ export default class RhsThread extends React.Component { return true; } - if (nextState.topRhsPostCreateAt !== this.state.topRhsPostCreateAt) { + if (nextState.topRhsPostId !== this.state.topRhsPostId) { return true; } @@ -185,20 +185,20 @@ export default class RhsThread extends React.Component { if (this.props.posts) { const childNodes = this.refs.rhspostlist.childNodes; const viewPort = this.refs.rhspostlist.getBoundingClientRect(); - let topRhsPostCreateAt = 0; + let topRhsPostId = ''; const offset = 100; // determine the top rhs comment assuming that childNodes and postsArray are of same length for (let i = 0; i < childNodes.length; i++) { if ((childNodes[i].offsetTop + viewPort.top) - offset > 0) { - topRhsPostCreateAt = this.props.posts[i].create_at; + topRhsPostId = this.props.posts[i].id; break; } } - if (topRhsPostCreateAt !== this.state.topRhsPostCreateAt) { + if (topRhsPostId !== this.state.topRhsPostId) { this.setState({ - topRhsPostCreateAt, + topRhsPostId, }); } } @@ -330,7 +330,7 @@ export default class RhsThread extends React.Component { { const nextState = rhsReducer( {}, { - type: ActionTypes.SEARCH_FLAGGED_POSTS_REQUEST, + type: SearchTypes.SEARCH_FLAGGED_POSTS_REQUEST, } ); @@ -85,7 +85,7 @@ describe('Reducers.RHS', () => { const nextState = rhsReducer( {}, { - type: ActionTypes.SEARCH_FLAGGED_POSTS_SUCCESS, + type: SearchTypes.SEARCH_FLAGGED_POSTS_SUCCESS, } ); diff --git a/selectors/posts.js b/selectors/posts.js index 0abe1d4a1f3a..423980e378f6 100644 --- a/selectors/posts.js +++ b/selectors/posts.js @@ -4,12 +4,9 @@ import {createSelector} from 'reselect'; import {getPost} from 'mattermost-redux/selectors/entities/posts'; import {getBool as getBoolPreference} from 'mattermost-redux/selectors/entities/preferences'; -import {shouldFilterJoinLeavePost} from 'mattermost-redux/utils/post_utils'; -import {createIdsSelector} from 'mattermost-redux/utils/helpers'; -import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; import {getGlobalItem} from 'selectors/storage'; -import {Preferences, StoragePrefixes, PostTypes, PostListRowListIds} from 'utils/constants'; +import {Preferences, StoragePrefixes} from 'utils/constants'; export const getEditingPost = createSelector( (state) => { @@ -42,75 +39,3 @@ export function isEmbedVisible(state, postId) { export function shouldShowJoinLeaveMessages(state) { return getBoolPreference(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() { - return createIdsSelector( - (state, props) => props.posts, - (state) => state.entities.posts.selectedPostId, - (state, props) => props.lastViewedAt, - (state, props) => props.indicateNewMessages, - getCurrentUser, - shouldShowJoinLeaveMessages, - (posts, selectedPostId, lastViewedAt, indicateNewMessages, currentUser, showJoinLeave) => { - if (!posts || posts.length === 0 || !currentUser) { - return { - postIds: [], - postsObjById: {}, - }; - } - - const postIds = []; - const postsObjById = {}; - - let lastDate = null; - let addedNewMessagesIndicator = false; - - // Iterating through the posts from oldest to newest - for (let i = posts.length - 1; i >= 0; i--) { - const post = posts[i]; - - if ( - !post || - (post.type === PostTypes.EPHEMERAL_ADD_TO_CHANNEL && !selectedPostId) - ) { - continue; - } - - // Filter out join/leave messages if necessary - if (shouldFilterJoinLeavePost(post, showJoinLeave, currentUser.username)) { - continue; - } - - // 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); - postDate.setHours(0, 0, 0, 0); - - if (!lastDate || lastDate.toDateString() !== postDate.toDateString()) { - postIds.unshift(PostListRowListIds.DATE_LINE + postDate); - lastDate = postDate; - } - - if ( - lastViewedAt && - post.create_at > lastViewedAt && - post.user_id !== currentUser.id && - !addedNewMessagesIndicator && - indicateNewMessages - ) { - postIds.unshift(PostListRowListIds.START_OF_NEW_MESSAGES); - addedNewMessagesIndicator = true; - } - postIds.unshift(post.id); - postsObjById[post.id] = post; - } - - return { - postIds, - postsObjById, - }; - } - ); -} diff --git a/utils/constants.jsx b/utils/constants.jsx index c6dc64539116..8f11d438f036 100644 --- a/utils/constants.jsx +++ b/utils/constants.jsx @@ -25,6 +25,7 @@ import windows10ThemeImage from 'images/themes/windows_dark.png'; import logoWebhook from 'images/webhook_icon.jpg'; import Permissions from 'mattermost-redux/constants/permissions'; +import * as PostListUtils from 'mattermost-redux/utils/post_list'; import {t} from 'utils/i18n'; @@ -107,14 +108,6 @@ export const ActionTypes = keyMirror({ UPDATE_MOBILE_VIEW: null, - SEARCH_FLAGGED_POSTS_REQUEST: null, - SEARCH_FLAGGED_POSTS_SUCCESS: null, - SEARCH_FLAGGED_POSTS_FAILURE: null, - - SEARCH_PINNED_POSTS_REQUEST: null, - SEARCH_PINNED_POSTS_SUCCESS: null, - SEARCH_PINNED_POSTS_FAILURE: null, - SET_NAVIGATION_BLOCKED: null, DEFER_NAVIGATION: null, CANCEL_NAVIGATION: null, @@ -582,8 +575,8 @@ export const Locations = { }; export const PostListRowListIds = { - DATE_LINE: 'date-', - START_OF_NEW_MESSAGES: 'START_OF_NEW_MESSAGES', + DATE_LINE: PostListUtils.DATE_LINE, + START_OF_NEW_MESSAGES: PostListUtils.START_OF_NEW_MESSAGES, CHANNEL_INTRO_MESSAGE: 'CHANNEL_INTRO_MESSAGE', MORE_MESSAGES_LOADER: 'MORE_MESSAGES_LOADER', MAX_MESSAGES_LOADED: 'MAX_MESSAGES_LOADED', diff --git a/utils/post_utils.jsx b/utils/post_utils.jsx index 82edf39d210f..a9d152652027 100644 --- a/utils/post_utils.jsx +++ b/utils/post_utils.jsx @@ -7,11 +7,12 @@ import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles' import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import {Permissions} from 'mattermost-redux/constants'; +import * as PostListUtils from 'mattermost-redux/utils/post_list'; import {canEditPost as canEditPostRedux} from 'mattermost-redux/utils/post_utils'; import store from 'stores/redux_store.jsx'; -import Constants from 'utils/constants.jsx'; +import Constants, {PostListRowListIds} from 'utils/constants.jsx'; import {formatWithRenderer} from 'utils/markdown'; import MentionableRenderer from 'utils/markdown/mentionable_renderer'; import * as Utils from 'utils/utils.jsx'; @@ -224,17 +225,32 @@ export function isErrorInvalidSlashCommand(error) { return false; } -export function getClosestValidPostIndex(postIds, index) { - let postIndex = index; - while (postIndex >= 0) { - const postId = postIds[postIndex]; - if (postId && postId.indexOf(Constants.PostListRowListIds.DATE_LINE) < 0 && - postId.indexOf(Constants.PostListRowListIds.START_OF_NEW_MESSAGES) < 0 && - postId !== 'CHANNEL_INTRO_MESSAGE' && - postId !== 'MORE_MESSAGES_LOADER') { - break; +// getLastPostId returns the most recent post ID in the given list of post IDs. This function is copied from +// mattermost-redux, except it also includes additional special IDs that are only used in the web app. +export function getLastPostId(postIds) { + for (let i = postIds.length - 1; i >= 0; i--) { + const item = postIds[i]; + + if ( + PostListUtils.isStartOfNewMessages(item) || + PostListUtils.isDateLine(item) || + item === PostListRowListIds.CHANNEL_INTRO_MESSAGE || + item === PostListRowListIds.MORE_MESSAGES_LOADER + ) { + // This is not a post at all + continue; } - postIndex--; + + if (PostListUtils.isCombinedUserActivityPost(item)) { + // This is a combined post, so find the first post ID from it + const combinedIds = PostListUtils.getPostIdsForCombinedUserActivityPost(item); + + return combinedIds[combinedIds.length - 1]; + } + + // This is a post ID + return item; } - return postIndex; + + return ''; }