diff --git a/actions/post_actions.jsx b/actions/post_actions.jsx index b6ef1c2e7ee7..0a9c989aa466 100644 --- a/actions/post_actions.jsx +++ b/actions/post_actions.jsx @@ -22,6 +22,7 @@ import { Constants, RHSStates, StoragePrefixes, + PostRequestTypes, } from 'utils/constants'; import {EMOJI_PATTERN} from 'utils/emoticons.jsx'; import * as UserAgent from 'utils/user_agent'; @@ -132,13 +133,9 @@ 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) { +export function loadPosts({channelId, postId, type}) { 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) { @@ -151,11 +148,13 @@ export function increasePostVisibility(channelId, focusedPostId) { channelId, }); - const page = Math.floor(currentPostVisibility / POST_INCREASE_AMOUNT); + const page = 0; let result; - if (focusedPostId) { - result = await dispatch(PostActions.getPostsBefore(channelId, focusedPostId, page, POST_INCREASE_AMOUNT)); + if (type === PostRequestTypes.BEFORE_ID) { + result = await dispatch(PostActions.getPostsBefore(channelId, postId, page, POST_INCREASE_AMOUNT)); + } else if (type === PostRequestTypes.AFTER_ID) { + result = await dispatch(PostActions.getPostsAfter(channelId, postId, page, POST_INCREASE_AMOUNT)); } else { result = await dispatch(PostActions.getPosts(channelId, page, POST_INCREASE_AMOUNT)); } @@ -183,6 +182,18 @@ export function increasePostVisibility(channelId, focusedPostId) { }; } +export function loadUnreads(channelId) { + return (dispatch) => { + dispatch({ + type: ActionTypes.INCREASE_POST_VISIBILITY, + data: channelId, + amount: Constants.POST_CHUNK_SIZE, + }); + + return dispatch(PostActions.getPostsUnread(channelId)); + }; +} + export function searchForTerm(term) { return (dispatch) => { dispatch(RhsActions.updateSearchTerms(term)); diff --git a/actions/post_actions.test.js b/actions/post_actions.test.js index 4112032d3bdf..1ce7324604b0 100644 --- a/actions/post_actions.test.js +++ b/actions/post_actions.test.js @@ -8,7 +8,7 @@ import {Posts} from 'mattermost-redux/constants'; import {SearchTypes} from 'mattermost-redux/action_types'; import * as Actions from 'actions/post_actions'; -import {Constants, ActionTypes, RHSStates} from 'utils/constants'; +import {Constants, ActionTypes, RHSStates, PostRequestTypes} from 'utils/constants'; const mockStore = configureStore([thunk]); @@ -18,10 +18,12 @@ jest.mock('mattermost-redux/actions/posts', () => ({ createPostImmediately: (...args) => ({type: 'MOCK_CREATE_POST_IMMEDIATELY', args}), getPosts: (...args) => ({type: 'MOCK_GET_POSTS', args}), getPostsBefore: (...args) => ({type: 'MOCK_GET_POSTS_BEFORE', args}), + getPostsAfter: (...args) => ({type: 'MOCK_GET_POSTS_AFTER', 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}), + getPostsUnread: (...args) => ({type: 'MOCK_GET_UNREADS_POSTS', args}), })); jest.mock('actions/emoji_actions', () => ({ @@ -226,13 +228,13 @@ describe('Actions.Posts', () => { expect(testStore.getActions()).toEqual([{type: ActionTypes.HIDE_EDIT_POST_MODAL}]); }); - test('increasePostVisibility', async () => { + test('loadPosts for getPosts', async () => { const testStore = await mockStore(initialState); - await testStore.dispatch(Actions.increasePostVisibility('current_channel_id')); + await testStore.dispatch(Actions.loadPosts({channelId: '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'}, + {args: ['current_channel_id', 0, 30], type: 'MOCK_GET_POSTS'}, { meta: {batch: true}, payload: [ @@ -241,11 +243,15 @@ describe('Actions.Posts', () => { type: 'BATCHING_REDUCER.BATCH', }, ]); + }); + + test('loadPosts for getPostsBefore', async () => { + const testStore = await mockStore(initialState); - await testStore.dispatch(Actions.increasePostVisibility('current_channel_id', 'latest_post_id')); + await testStore.dispatch(Actions.loadPosts({channelId: 'current_channel_id', type: PostRequestTypes.BEFORE_ID, postId: 'test'})); expect(testStore.getActions()).toEqual([ {channelId: 'current_channel_id', data: true, type: 'LOADING_POSTS'}, - {args: ['current_channel_id', 2, 30], type: 'MOCK_GET_POSTS'}, + {args: ['current_channel_id', 'test', 0, 30], type: 'MOCK_GET_POSTS_BEFORE'}, { meta: {batch: true}, payload: [ @@ -253,11 +259,16 @@ describe('Actions.Posts', () => { ], type: 'BATCHING_REDUCER.BATCH', }, + ]); + }); + + test('loadPosts for getPostsAfter', async () => { + const testStore = await mockStore(initialState); + + await testStore.dispatch(Actions.loadPosts({channelId: 'current_channel_id', type: PostRequestTypes.AFTER_ID, postId: 'test'})); + expect(testStore.getActions()).toEqual([ {channelId: 'current_channel_id', data: true, type: 'LOADING_POSTS'}, - { - args: ['current_channel_id', 'latest_post_id', 2, 30], - type: 'MOCK_GET_POSTS_BEFORE', - }, + {args: ['current_channel_id', 'test', 0, 30], type: 'MOCK_GET_POSTS_AFTER'}, { meta: {batch: true}, payload: [ @@ -268,6 +279,16 @@ describe('Actions.Posts', () => { ]); }); + test('loadUnreads', async () => { + const testStore = await mockStore(initialState); + + await testStore.dispatch(Actions.loadUnreads('current_channel_id')); + expect(testStore.getActions()).toEqual([ + {amount: 60, data: 'current_channel_id', type: 'INCREASE_POST_VISIBILITY'}, + {args: ['current_channel_id'], type: 'MOCK_GET_UNREADS_POSTS'}, + ]); + }); + test('searchForTerm', async () => { const testStore = await mockStore(initialState); diff --git a/actions/views/channel.js b/actions/views/channel.js index 6a24bd18a25a..734b9e960bf2 100644 --- a/actions/views/channel.js +++ b/actions/views/channel.js @@ -2,11 +2,13 @@ // See LICENSE.txt for license information. import {leaveChannel as leaveChannelRedux, joinChannel, unfavoriteChannel} from 'mattermost-redux/actions/channels'; +import {getPostsSince, getPostsBefore} from 'mattermost-redux/actions/posts'; 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'; import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences'; import {isFavoriteChannel} from 'mattermost-redux/utils/channel_utils'; +import {getNewestPostIdFromPosts, getOldestPostIdFromPosts} from 'mattermost-redux/utils/post_utils'; import {autocompleteUsers} from 'mattermost-redux/actions/users'; import {openDirectChannelToUserId} from 'actions/channel_actions.jsx'; @@ -16,6 +18,8 @@ import {browserHistory} from 'utils/browser_history'; import {Constants, ActionTypes} from 'utils/constants.jsx'; import {isMobile} from 'utils/utils.jsx'; +const POST_INCREASE_AMOUNT = Constants.POST_CHUNK_SIZE / 2; + export function checkAndSetMobileView() { return (dispatch) => { dispatch({ @@ -117,3 +121,39 @@ export function autocompleteUsersInChannel(prefix, channelId) { }; } +export function changeChannelPostsStatus(params) { + // takes params in the format of {channelId, atEnd: true/false} or {channelId, atStart: true/false} + + return (dispatch) => { + dispatch({ + type: ActionTypes.CHANNEL_POSTS_STATUS, + data: params, + }); + }; +} + +export function channelSyncCompleted(channelId) { + return async (dispatch) => { + dispatch({ + type: ActionTypes.CHANNEL_SYNC_STATUS, + data: channelId, + }); + }; +} + +export function syncChannelPosts({channelId, channelPostsStatus, lastDisconnectAt, posts}) { + return async (dispatch) => { + if (channelPostsStatus.atEnd) { + await dispatch(getPostsSince(channelId, lastDisconnectAt)); + } else { + let data; + const oldestPostId = getOldestPostIdFromPosts(posts); + let newestMessageId = getNewestPostIdFromPosts(posts); + do { + ({data} = await dispatch(getPostsBefore(channelId, newestMessageId, 0, POST_INCREASE_AMOUNT))); // eslint-disable-line no-await-in-loop + newestMessageId = data.order[data.order.length - 1]; + } while (data && !data.posts[oldestPostId]); + } + dispatch(channelSyncCompleted(channelId)); + }; +} diff --git a/actions/views/channel.test.js b/actions/views/channel.test.js index bd0c531d44b5..9505606c7891 100644 --- a/actions/views/channel.test.js +++ b/actions/views/channel.test.js @@ -10,6 +10,7 @@ import {leaveChannel} from 'mattermost-redux/actions/channels'; 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]); @@ -104,4 +105,22 @@ describe('channel view actions', () => { expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/channels/${General.DEFAULT_CHANNEL}`); }); }); + + test('changeChannelPostsStatus', async () => { + const params = {}; + await store.dispatch(Actions.changeChannelPostsStatus(params)); + expect(store.getActions()).toEqual([{ + type: ActionTypes.CHANNEL_POSTS_STATUS, + data: params, + }]); + }); + + test('channelSyncCompleted', async () => { + const channelId = 'channelId'; + await store.dispatch(Actions.channelSyncCompleted(channelId)); + expect(store.getActions()).toEqual([{ + type: ActionTypes.CHANNEL_SYNC_STATUS, + data: channelId, + }]); + }); }); diff --git a/actions/websocket_actions.jsx b/actions/websocket_actions.jsx index 6f55373d2508..f0493cdacdd4 100644 --- a/actions/websocket_actions.jsx +++ b/actions/websocket_actions.jsx @@ -24,7 +24,7 @@ import { import {setServerVersion} from 'mattermost-redux/actions/general'; import {clearErrors, logError} from 'mattermost-redux/actions/errors'; -import {getPosts, getProfilesAndStatusesForPosts, getCustomEmojiForReaction} from 'mattermost-redux/actions/posts'; +import {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'; @@ -32,6 +32,7 @@ import {getCurrentUser, getCurrentUserId, getStatusForUserId, getUser} from 'mat import {getMyTeams, getCurrentRelativeTeamUrl, getCurrentTeamId, getCurrentTeamUrl} from 'mattermost-redux/selectors/entities/teams'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getChannel, getCurrentChannel, getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {getMyChannelMemberships} from 'mattermost-redux/selectors/entities/common'; import {openModal} from 'actions/views/modals'; import {incrementWsErrorCount, resetWsErrorCount} from 'actions/views/system'; @@ -129,6 +130,10 @@ export function reconnect(includeWebSocket = true) { reconnectWebSocket(); } + dispatch({ + type: GeneralTypes.WEBSOCKET_SUCCESS, + }); + loadPluginsIfNecessary(); Object.values(pluginReconnectHandlers).forEach((handler) => { @@ -140,7 +145,6 @@ export function reconnect(includeWebSocket = true) { const currentTeamId = getState().entities.teams.currentTeamId; if (currentTeamId) { dispatch(loadChannelsForCurrentUser()); - dispatch(getPosts(getCurrentChannelId(getState()))); StatusActions.loadStatusesForChannelAndSidebar(); dispatch(TeamActions.getMyTeamUnreads()); } @@ -190,13 +194,26 @@ export function unregisterAllPluginWebSocketEvents(pluginId) { } function handleFirstConnect() { - dispatch(clearErrors); + dispatch(batchActions([ + {type: GeneralTypes.WEBSOCKET_SUCCESS}, + ])); } function handleClose(failCount) { if (failCount > MAX_WEBSOCKET_FAILS) { dispatch(logError({type: 'critical', message: AnnouncementBarMessages.WEBSOCKET_PORT_ERROR}, true)); } + + dispatch(batchActions([{ + type: GeneralTypes.WEBSOCKET_FAILURE, + }, { + type: ActionTypes.ALL_CHANNEL_SYNC_STATUS, + data: { + channelIds: Object.keys(getMyChannelMemberships(getState())), + status: false, + }, + }])); + dispatch(incrementWsErrorCount()); } diff --git a/components/channel_view/channel_view.jsx b/components/channel_view/channel_view.jsx index d5ebcfb5345f..e43e059840ce 100644 --- a/components/channel_view/channel_view.jsx +++ b/components/channel_view/channel_view.jsx @@ -15,6 +15,7 @@ import PostView from 'components/post_view'; import TutorialView from 'components/tutorial'; import {clearMarks, mark, measure, trackEvent} from 'actions/diagnostics_actions.jsx'; import FormattedMarkdownMessage from 'components/formatted_markdown_message'; +import LoadingScreen from 'components/loading_screen.jsx'; export default class ChannelView extends React.PureComponent { static propTypes = { @@ -26,6 +27,12 @@ export default class ChannelView extends React.PureComponent { showTutorial: PropTypes.bool.isRequired, channelIsArchived: PropTypes.bool.isRequired, viewArchivedChannels: PropTypes.bool.isRequired, + + /** + * For indicating channelLoading + * To prevent child views from loading with wrong channel. + */ + channelLoading: PropTypes.bool, actions: PropTypes.shape({ goToLastViewedChannel: PropTypes.func.isRequired, }), @@ -162,9 +169,17 @@ export default class ChannelView extends React.PureComponent { - + { this.props.channelLoading ? ( + + ) : ( + + )} {createPost} ); diff --git a/components/channel_view/index.js b/components/channel_view/index.js index 4b5155c07089..53d71981119f 100644 --- a/components/channel_view/index.js +++ b/components/channel_view/index.js @@ -4,14 +4,15 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import {createSelector} from 'reselect'; +import {withRouter} from 'react-router-dom'; import {getInt} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {withRouter} from 'react-router-dom'; +import {getTeamByName} from 'mattermost-redux/selectors/entities/teams'; import {getDirectTeammate} from 'utils/utils.jsx'; -import {TutorialSteps, Preferences} from 'utils/constants.jsx'; +import {TutorialSteps, Preferences, Constants} from 'utils/constants.jsx'; import {goToLastViewedChannel} from 'actions/views/channel'; @@ -27,13 +28,32 @@ const getDeactivatedChannel = createSelector( } ); -function mapStateToProps(state) { +function mapStateToProps(state, ownProps) { const channel = getCurrentChannel(state); - + let channelLoading = false; const config = getConfig(state); const enableTutorial = config.EnableTutorial === 'true'; const tutorialStep = getInt(state, Preferences.TUTORIAL_STEP, getCurrentUserId(state), TutorialSteps.FINISHED); const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; + const team = getTeamByName(state, ownProps.match.params.team); + + if (channel) { + if (channel.type !== Constants.DM_CHANNEL && channel.type !== Constants.GM_CHANNEL) { + if (channel.name !== ownProps.match.params.identifier) { + channelLoading = true; + } + + if (channel.team_id && channel.team_id !== team.id) { + channelLoading = true; + } + } + } else { + channelLoading = true; + } + + if (channel && (channel.team_id && channel.team_id !== team.id)) { + channelLoading = true; + } return { channelId: channel ? channel.id : '', @@ -41,6 +61,7 @@ function mapStateToProps(state) { showTutorial: enableTutorial && tutorialStep <= TutorialSteps.INTRO_SCREENS, channelIsArchived: channel ? channel.delete_at !== 0 : false, viewArchivedChannels, + channelLoading, }; } diff --git a/components/permalink_view/__snapshots__/permalink_view.test.jsx.snap b/components/permalink_view/__snapshots__/permalink_view.test.jsx.snap index bdfd8379ed18..c52717f0e135 100644 --- a/components/permalink_view/__snapshots__/permalink_view.test.jsx.snap +++ b/components/permalink_view/__snapshots__/permalink_view.test.jsx.snap @@ -8,7 +8,7 @@ exports[`components/PermalinkView should match snapshot 1`] = ` - @@ -41,7 +41,7 @@ exports[`components/PermalinkView should match snapshot with archived channel 1` - diff --git a/components/post_view/__snapshots__/post_view.test.jsx.snap b/components/post_view/__snapshots__/post_view.test.jsx.snap new file mode 100644 index 000000000000..937bac16b9c6 --- /dev/null +++ b/components/post_view/__snapshots__/post_view.test.jsx.snap @@ -0,0 +1,106 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/post_view/post_list Should call postsOnLoad 1`] = ` +
+
+
+
+
+
+
+`; + +exports[`components/post_view/post_list init snapshot for max postVisibility 1`] = ` +
+
+
+
+
+
+
+`; + +exports[`components/post_view/post_list snapshot for loading when there are no posts 1`] = ` +
+ +
+`; + +exports[`components/post_view/post_list snapshot with couple of posts 1`] = ` +
+
+
+
+ +
+
+
+
+`; diff --git a/components/post_view/channel_intro_message/index.js b/components/post_view/channel_intro_message/index.js index 0758acd4be1d..f5b19657a538 100644 --- a/components/post_view/channel_intro_message/index.js +++ b/components/post_view/channel_intro_message/index.js @@ -4,9 +4,11 @@ import {connect} from 'react-redux'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {isCurrentChannelReadOnly} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentChannel, isCurrentChannelReadOnly} from 'mattermost-redux/selectors/entities/channels'; import {getProfilesInCurrentChannel} from 'mattermost-redux/selectors/entities/users'; +import {get} from 'mattermost-redux/selectors/entities/preferences'; +import {Preferences} from 'utils/constants.jsx'; import {getCurrentLocale} from 'selectors/i18n'; import ChannelIntroMessage from './channel_intro_message.jsx'; @@ -17,10 +19,12 @@ function mapStateToProps(state) { const isReadOnly = isCurrentChannelReadOnly(state); return { + channel: getCurrentChannel(state), locale: getCurrentLocale(state), channelProfiles: getProfilesInCurrentChannel(state), enableUserCreation, isReadOnly, + fullWidth: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN, }; } diff --git a/components/post_view/index.js b/components/post_view/index.js index 6e273225ea5b..7f306ce35389 100644 --- a/components/post_view/index.js +++ b/components/post_view/index.js @@ -3,24 +3,28 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {getPosts, getPostsAfter, getPostsBefore, getPostThread} from 'mattermost-redux/actions/posts'; +import {getPostThread} from 'mattermost-redux/actions/posts'; import {getChannel} from 'mattermost-redux/selectors/entities/channels'; -import {makeGetPostsAroundPost, makeGetPostsInChannel} from 'mattermost-redux/selectors/entities/posts'; -import {get} from 'mattermost-redux/selectors/entities/preferences'; +import {makeGetPostsAroundPost, makeGetPostsInChannel, getPostIdsInCurrentChannel} from 'mattermost-redux/selectors/entities/posts'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {getMyChannelMemberships} from 'mattermost-redux/selectors/entities/common'; -import {increasePostVisibility} from 'actions/post_actions.jsx'; -import {checkAndSetMobileView} from 'actions/views/channel'; -import {Preferences} from 'utils/constants.jsx'; +import {makeGetChannelPostStatus, makeGetChannelSyncStatus} from 'selectors/views/channel'; +import {loadPosts, loadUnreads} from 'actions/post_actions'; +import {changeChannelPostsStatus, channelSyncCompleted, syncChannelPosts, checkAndSetMobileView} from 'actions/views/channel'; +import {getSocketStatus} from 'selectors/views/websocket'; -import PostList from './post_list.jsx'; +import PostView from './post_view.jsx'; function makeMapStateToProps() { const getPostsInChannel = makeGetPostsInChannel(); const getPostsAroundPost = makeGetPostsAroundPost(); + const getChannelPostStatus = makeGetChannelPostStatus(); + const getChannelSyncStatus = makeGetChannelSyncStatus(); return function mapStateToProps(state, ownProps) { const postVisibility = state.views.channel.postVisibility[ownProps.channelId]; + const member = getMyChannelMemberships(state)[ownProps.channelId]; let posts; if (ownProps.focusedPostId) { @@ -34,10 +38,12 @@ function makeMapStateToProps() { lastViewedAt: state.views.channel.lastChannelViewTime[ownProps.channelId], posts, postVisibility, - loadingPosts: state.views.channel.loadingPosts[ownProps.channelId], - focusedPostId: ownProps.focusedPostId, currentUserId: getCurrentUserId(state), - fullWidth: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN, + member, + channelPostsStatus: getChannelPostStatus(state, ownProps.channelId), + postIdsInCurrentChannel: getPostIdsInCurrentChannel(state), + channelSyncStatus: getChannelSyncStatus(state, ownProps.channelId), + socketStatus: getSocketStatus(state), }; }; } @@ -45,14 +51,15 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ - getPosts, - getPostsBefore, - getPostsAfter, getPostThread, - increasePostVisibility, + loadUnreads, + loadPosts, + changeChannelPostsStatus, + channelSyncCompleted, + syncChannelPosts, checkAndSetMobileView, }, dispatch), }; } -export default connect(makeMapStateToProps, mapDispatchToProps)(PostList); +export default connect(makeMapStateToProps, mapDispatchToProps)(PostView); diff --git a/components/post_view/post_list.jsx b/components/post_view/post_list.jsx index 92930891453d..e13c93acdbaa 100644 --- a/components/post_view/post_list.jsx +++ b/components/post_view/post_list.jsx @@ -3,30 +3,28 @@ import PropTypes from 'prop-types'; import React from 'react'; -import ReactDOM from 'react-dom'; import {FormattedMessage} from 'react-intl'; +import ReactDOM from 'react-dom'; import {isUserActivityPost} from 'mattermost-redux/utils/post_utils'; -import Constants, {PostTypes} from 'utils/constants.jsx'; +import Constants, {PostTypes, PostRequestTypes} from 'utils/constants.jsx'; +import {isFromWebhook} from 'utils/post_utils.jsx'; +import * as Utils from 'utils/utils.jsx'; +import * as UserAgent from 'utils/user_agent.jsx'; import DelayedAction from 'utils/delayed_action.jsx'; -import EventTypes from 'utils/event_types.jsx'; import GlobalEventEmitter from 'utils/global_event_emitter.jsx'; -import * as UserAgent from 'utils/user_agent.jsx'; -import * as Utils from 'utils/utils.jsx'; -import {isFromWebhook} from 'utils/post_utils.jsx'; +import EventTypes from 'utils/event_types.jsx'; -import LoadingScreen from 'components/loading_screen.jsx'; import DateSeparator from 'components/post_view/date_separator.jsx'; +import Post from 'components/post_view/post'; import FloatingTimestamp from './floating_timestamp.jsx'; import NewMessageIndicator from './new_message_indicator.jsx'; -import Post from './post'; import ScrollToBottomArrows from './scroll_to_bottom_arrows.jsx'; import CreateChannelIntroMessage from './channel_intro_message'; const CLOSE_TO_BOTTOM_SCROLL_MARGIN = 10; -const POSTS_PER_PAGE = Constants.POST_CHUNK_SIZE / 2; const MAX_EXTRA_PAGES_LOADED = 10; const MAX_NUMBER_OF_AUTO_RETRIES = 3; @@ -43,67 +41,50 @@ export default class PostList extends React.PureComponent { posts: PropTypes.array, /** - * The number of posts that should be rendered + * Timestamp used for populating new messages indicator */ - postVisibility: PropTypes.number, + lastViewedAt: PropTypes.number, /** - * The channel the posts are in + * Used for excluding own messages for new messages indicator */ - channel: PropTypes.object.isRequired, + currentUserId: PropTypes.string, /** - * The last time the channel was viewed, sets the new message separator + * Data used for determining more messages state at the bottom */ - lastViewedAt: PropTypes.number, + newerPosts: PropTypes.shape({ + loading: PropTypes.bool, + allLoaded: PropTypes.bool, + }), /** - * The user id of the logged in user + * Data used for determining more messages state at the top */ - currentUserId: PropTypes.string, + olderPosts: PropTypes.shape({ + loading: PropTypes.bool, + allLoaded: PropTypes.bool, + }), /** - * Set to focus this post + * Function to get older posts in the channel */ - focusedPostId: PropTypes.string, + loadOlderPosts: PropTypes.func.isRequired, /** - * Whether to display the channel intro at full width + * Function to get newer posts in the channel */ - fullWidth: PropTypes.bool, - - 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, - - /** - * Function to increase the number of posts being rendered - */ - increasePostVisibility: PropTypes.func.isRequired, - - /** - * Function to check and set if app is in mobile view - */ - checkAndSetMobileView: PropTypes.func.isRequired, - }).isRequired, + loadNewerPosts: PropTypes.func.isRequired, + + /** + * Used for disabling loading more messages + */ + postVisibility: PropTypes.number, + + /** + * Used for focussing post on load + */ + focusedPostId: PropTypes.string, } constructor(props) { @@ -122,20 +103,14 @@ export default class PostList extends React.PureComponent { this.atBottom = false; this.state = { - atEnd: false, unViewedCount: 0, - isDoingInitialLoad: true, isScrolling: false, - lastViewed: props.lastViewedAt, autoRetryEnable: true, }; } componentDidMount() { - this.loadPosts(this.props.channel.id, this.props.focusedPostId); - this.props.actions.checkAndSetMobileView(); GlobalEventEmitter.addListener(EventTypes.POST_LIST_SCROLL_CHANGE, this.handleResize); - window.addEventListener('resize', this.handleWindowResize); this.initialScroll(); @@ -150,7 +125,7 @@ export default class PostList extends React.PureComponent { if (this.postListRef.current) { const previousScrollTop = this.postListRef.current.scrollTop; const previousScrollHeight = this.postListRef.current.scrollHeight; - const wasAtBottom = this.checkBottom(); + const wasAtBottom = this.checkBottom() && this.props.newerPosts.allLoaded; return { previousScrollTop, @@ -163,26 +138,10 @@ export default class PostList extends React.PureComponent { componentDidUpdate(prevProps, prevState, snapshot) { this.previousScrollHeight = snapshot && snapshot.previousScrollHeight; - if (this.props.focusedPostId && this.props.focusedPostId !== prevProps.focusedPostId) { - this.hasScrolledToFocusedPost = false; - this.hasScrolledToNewMessageSeparator = false; - this.loadPosts(this.props.channel.id, this.props.focusedPostId); - } else if (this.props.channel && (!prevProps.channel || this.props.channel.id !== prevProps.channel.id)) { - this.hasScrolled = false; - this.hasScrolledToFocusedPost = false; - this.hasScrolledToNewMessageSeparator = false; - this.atBottom = false; - this.extraPagesLoaded = 0; - - this.setState({atEnd: false, isDoingInitialLoad: !this.props.posts, unViewedCount: 0}); // eslint-disable-line react/no-did-update-set-state - - this.loadPosts(this.props.channel.id); - } - this.loadPostsToFillScreenIfNecessary(); // Do not update scrolling unless posts, visibility or intro message change - if (this.props.posts === prevProps.posts && this.props.postVisibility === prevProps.postVisibility && this.state.atEnd === prevState.atEnd && this.state.loadingPosts === prevState.loadingPosts) { + if (this.props.posts === prevProps.posts && this.props.postVisibility === prevProps.postVisibility) { return; } @@ -203,31 +162,6 @@ export default class PostList extends React.PureComponent { } const postlistScrollHeight = postList.scrollHeight; - // Scroll to focused post on first load - const focusedPost = this.refs[this.props.focusedPostId]; - if (focusedPost && this.props.posts) { - if (!this.hasScrolledToFocusedPost) { - const element = ReactDOM.findDOMNode(focusedPost); - const rect = element.getBoundingClientRect(); - const listHeight = postList.clientHeight / 2; - postList.scrollTop = (rect.top - listHeight) + postList.scrollTop; - this.atBottom = this.checkBottom(); - } else if (snapshot.previousScrollHeight !== postlistScrollHeight && posts[0].id === prevPosts[0].id) { - postList.scrollTop = snapshot.previousScrollTop + (postlistScrollHeight - snapshot.previousScrollHeight); - } - return; - } - - const didInitialScroll = this.initialScroll(); - - if (posts.length >= POSTS_PER_PAGE) { - this.hasScrolledToNewMessageSeparator = true; - } - - if (didInitialScroll) { - return; - } - if (postList && prevPosts && posts && posts[0] && prevPosts[0]) { // A new message was posted, so scroll to bottom if user // was already scrolled close to bottom @@ -235,7 +169,7 @@ export default class PostList extends React.PureComponent { const postId = posts[0].id; const prevPostId = prevPosts[0].id; const pendingPostId = posts[0].pending_post_id; - if (postId !== prevPostId || pendingPostId === prevPostId) { + if ((postId !== prevPostId || pendingPostId === prevPostId) && this.props.newerPosts.allLoaded) { // If already scrolled to bottom if (snapshot.wasAtBottom) { doScrollToBottom = true; @@ -254,7 +188,7 @@ export default class PostList extends React.PureComponent { } // New posts added at the top, maintain scroll position - if (snapshot.previousScrollHeight !== postlistScrollHeight && posts[0].id === prevPosts[0].id) { + if (snapshot.previousScrollHeight !== postlistScrollHeight && (posts[0].id === prevPosts[0].id)) { window.requestAnimationFrame(() => { postList.scrollTop = snapshot.previousScrollTop + (postlistScrollHeight - snapshot.previousScrollHeight); }); @@ -267,7 +201,7 @@ export default class PostList extends React.PureComponent { return; } - if (this.state.isDoingInitialLoad || this.state.loadingPosts) { + if (this.props.olderPosts.loading) { // Should already be loading posts return; } @@ -277,7 +211,7 @@ export default class PostList extends React.PureComponent { return; } - if (this.state.atEnd || this.postListContentRef.current.scrollHeight >= this.postListRef.current.clientHeight) { + if (this.props.olderPosts.allLoaded || this.postListContentRef.current.scrollHeight >= this.postListRef.current.clientHeight) { // Screen is full return; } @@ -289,38 +223,34 @@ export default class PostList extends React.PureComponent { return; } - await this.loadMorePosts(); + await this.loadMoreOlderPosts(); this.extraPagesLoaded += 1; }; // Scroll to new message indicator or bottom on first load. Returns true // if we just scrolled for the initial load. initialScroll = () => { - if (this.hasScrolledToNewMessageSeparator) { - // Already scrolled to new messages indicator - return false; - } - const postList = this.postListRef.current; - const posts = this.props.posts; - if (!postList || !posts) { - // Not able to do initial scroll yet - return false; - } + const {posts, focusedPostId} = this.props; const messageSeparator = this.refs.newMessageSeparator; - // Scroll to new message indicator since we have unread posts and we can't show every new post in the screen - if (messageSeparator && (postList.scrollHeight - messageSeparator.offsetTop) > postList.clientHeight) { + if (focusedPostId) { + const focusedPost = this.refs[this.props.focusedPostId]; + const element = ReactDOM.findDOMNode(focusedPost); + const rect = element.getBoundingClientRect(); + const listHeight = postList.clientHeight / 2; + postList.scrollTop = (rect.top - listHeight) + postList.scrollTop; + this.atBottom = this.checkBottom(); + } else if (messageSeparator && (postList.scrollHeight - messageSeparator.offsetTop) > postList.clientHeight) { + // Scroll to new message indicator since we have unread posts and we can't show every new post in the screen messageSeparator.scrollIntoView(); this.setUnreadsBelow(posts, this.props.currentUserId); - return true; + } else { + // Scroll to bottom since we don't have unread posts or we can show every new post in the screen + this.atBottom = true; + postList.scrollTop = postList.scrollHeight; } - - // Scroll to bottom since we don't have unread posts or we can show every new post in the screen - this.atBottom = true; - postList.scrollTop = postList.scrollHeight; - return true; } setUnreadsBelow = (posts, currentUserId) => { @@ -368,71 +298,59 @@ export default class PostList extends React.PureComponent { this.resizeAnimationFrame = window.requestAnimationFrame(() => { const postList = this.postListRef.current; const messageSeparator = this.refs.newMessageSeparator; - const doScrollToBottom = this.atBottom || forceScrollToBottom; + const doScrollToBottom = (this.atBottom && this.props.newerPosts.allLoaded) || forceScrollToBottom; if (postList) { if (doScrollToBottom) { postList.scrollTop = postList.scrollHeight; - } else if (!this.hasScrolled && messageSeparator) { + } else if (messageSeparator) { const element = ReactDOM.findDOMNode(messageSeparator); element.scrollIntoView(); this.atBottom = this.checkBottom(); } this.previousScrollHeight = postList.scrollHeight; } - this.props.actions.checkAndSetMobileView(); + + // this.props.actions.checkAndSetMobileView(); }); } - loadPosts = async (channelId, focusedPostId) => { - 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); + loadMoreOlderPosts = () => { + return this.loadMorePosts(PostRequestTypes.BEFORE_ID); + } - const result = await getPostsBeforeAsync; - posts = result.data; - await getPostsAfterAsync; - await getPostThreadAsync; + loadMoreNewerPosts = () => { + this.loadingNewPosts = true; + return this.loadMorePosts(PostRequestTypes.AFTER_ID); + } - this.hasScrolledToFocusedPost = true; + loadMorePosts = async (type) => { + let error; + if (type === PostRequestTypes.BEFORE_ID) { + ({error} = await this.props.loadOlderPosts()); } else { - const result = await this.props.actions.getPosts(channelId, 0, POSTS_PER_PAGE); - posts = result.data; - - if (!this.checkBottom()) { - const postsArray = posts.order.map((id) => posts.posts[id]); - this.setUnreadsBelow(postsArray, this.props.currentUserId); - } - - this.hasScrolledToNewMessageSeparator = true; + ({error} = await this.props.loadNewerPosts()); } - this.setState({ - isDoingInitialLoad: false, - atEnd: Boolean(posts && posts.order.length < POSTS_PER_PAGE), - }); - } - - loadMorePosts = async () => { - const {moreToLoad, error} = await this.props.actions.increasePostVisibility(this.props.channel.id, this.props.focusedPostId); if (error) { if (this.autoRetriesCount < MAX_NUMBER_OF_AUTO_RETRIES) { this.autoRetriesCount++; - this.loadMorePosts(); + if (type === PostRequestTypes.BEFORE_ID) { + this.loadMoreOlderPosts(); + } else { + this.loadMoreNewerPosts(); + } } else { this.setState({autoRetryEnable: false}); } } else { this.setState({ - atEnd: !moreToLoad && this.props.posts.length < this.props.postVisibility, autoRetryEnable: true, - loadingPosts: false, }); if (!this.state.autoRetryEnable) { this.autoRetriesCount = 0; } + this.loadingNewPosts = false; } } @@ -443,11 +361,11 @@ export default class PostList extends React.PureComponent { this.scrollAnimationFrame = window.requestAnimationFrame(() => { // Only count as user scroll if we've already performed our first load scroll - this.hasScrolled = this.hasScrolledToNewMessageSeparator || this.hasScrolledToFocusedPost; const postList = this.postListRef.current; const postListScrollTop = postList.scrollTop; + const postlistScrollHeight = postList.scrollHeight; - if (postList.scrollHeight === this.previousScrollHeight) { + if (postlistScrollHeight === this.previousScrollHeight) { this.atBottom = this.checkBottom(); } @@ -467,19 +385,29 @@ export default class PostList extends React.PureComponent { }); } - let shouldLoadPosts = false; + let shouldLoadOldPosts = false; + let shouldLoadNewPosts = false; + + const postlistClientHeight = postList.clientHeight; const scrollHeightAoveFoldForLoad = LOADPOSTS_SCROLL_RATIO * (postList.scrollHeight - postList.clientHeight); if (postListScrollTop < LOADPOSTS_MIN_HEIGHT) { - shouldLoadPosts = true; + shouldLoadOldPosts = true; } else if ((postListScrollTop < LOADPOSTS_MAX_HEIGHT) && (postListScrollTop < scrollHeightAoveFoldForLoad)) { - shouldLoadPosts = true; + shouldLoadOldPosts = true; + } else if (postlistClientHeight + postListScrollTop >= postlistScrollHeight - LOADPOSTS_MIN_HEIGHT) { + shouldLoadNewPosts = true; + } + + if (shouldLoadOldPosts && !this.props.olderPosts.loading && !this.props.olderPosts.allLoaded && this.state.autoRetryEnable) { + this.loadMoreOlderPosts(); } - if (shouldLoadPosts && !this.state.loadingPosts && !this.state.atEnd && this.state.autoRetryEnable) { - this.setState({loadingPosts: true}); - this.loadMorePosts(); + if (shouldLoadNewPosts && !this.props.newerPosts.loading && !this.props.newerPosts.allLoaded && this.state.autoRetryEnable) { + this.loadingNewPosts = true; + this.loadMoreNewerPosts(); } + this.scrollStopAction.fireAfter(Constants.SCROLL_DELAY); }); } @@ -609,76 +537,76 @@ export default class PostList extends React.PureComponent { return this.postListRef.current; } + makeLoadingPlaceHolder = () => { + return ( +
+
+
+
+
+
+
+ ); + } + + makeLoadMoreMessagesHandler = (type) => { + const clickHandler = type === 'top' ? this.loadMoreOlderPosts : this.loadMoreNewerPosts; + + return ( + + ); + } + + makeMaxMessagesLoadedMessage = () => { + return ( +
+ +
+ ); + } + render() { const posts = this.props.posts || []; - const channel = this.props.channel; - - if ((posts.length === 0 && this.state.isDoingInitialLoad) || channel == null) { - return ( -
- -
- ); - } let topRow; - if (this.state.atEnd) { - topRow = ( - - ); - } else if (this.props.postVisibility >= Constants.MAX_POST_VISIBILITY) { - topRow = ( -
- -
- ); - } else if (this.state.isDoingInitialLoad) { - topRow = ; + let bottomRow; + if (this.props.postVisibility >= Constants.MAX_POST_VISIBILITY) { + topRow = this.makeMaxMessagesLoadedMessage(); + bottomRow = topRow; } else if (this.state.autoRetryEnable) { - topRow = ( -
-
-
-
-
-
-
- ); + topRow = this.makeLoadingPlaceHolder(); + bottomRow = topRow; } else { + topRow = this.makeLoadMoreMessagesHandler('top'); + bottomRow = this.makeLoadMoreMessagesHandler('bottom'); + } + + if (this.props.olderPosts.allLoaded) { topRow = ( - + ); } - const topPostCreateAt = this.state.topPost ? this.state.topPost.create_at : 0; - - let postVisibility = this.props.postVisibility; - - // In focus mode there's an extra (Constants.POST_CHUNK_SIZE / 2) posts to show - if (this.props.focusedPostId) { - postVisibility += Constants.POST_CHUNK_SIZE / 2; + if (this.props.newerPosts.allLoaded) { + bottomRow = null; } + const topPostCreateAt = this.state.topPost ? this.state.topPost.create_at : 0; + return (
@@ -708,7 +635,8 @@ export default class PostList extends React.PureComponent { className='post-list__content' > {topRow} - {this.createPosts(posts.slice(0, postVisibility))} + {this.createPosts(posts)} + {bottomRow}
diff --git a/components/post_view/post_view.jsx b/components/post_view/post_view.jsx new file mode 100644 index 000000000000..07c40fb96550 --- /dev/null +++ b/components/post_view/post_view.jsx @@ -0,0 +1,358 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import PropTypes from 'prop-types'; +import {getNewestPostIdFromPosts, getOldestPostIdFromPosts} from 'mattermost-redux/utils/post_utils'; + +import LoadingScreen from 'components/loading_screen.jsx'; +import Constants, {PostRequestTypes} from 'utils/constants.jsx'; + +import PostList from './post_list.jsx'; + +const POSTS_PER_PAGE = Constants.POST_CHUNK_SIZE / 2; + +export default class PostView extends React.PureComponent { + static propTypes = { + + /** + * Array of posts in the channel, ordered from newest to oldest + */ + posts: PropTypes.array, + + /** + * The channel the posts are in + */ + channelId: PropTypes.string, + + /** + * Used for disabling loading more messages + */ + postVisibility: PropTypes.number, + + /** + * Used for showing more message indicators and channel intro message + */ + channelPostsStatus: PropTypes.object, + + /** + * Used for unread count + */ + currentUserId: PropTypes.string, + + /** + * Used for unread count + */ + lastViewedAt: PropTypes.number, + + /** + * Flag used to determine if channel has to call sync actions. + */ + channelSyncStatus: PropTypes.bool, + + /** + * Used for websocket connect change to trigger sync. + */ + socketStatus: PropTypes.object, + + /** + * Used for determining if we reached to the top of channel. + */ + postIdsInCurrentChannel: PropTypes.array, + + /* + * To get posts for perma view + */ + focusedPostId: PropTypes.string, + + actions: PropTypes.shape({ + + /* + * Get post for permaview + */ + getPostThread: PropTypes.func.isRequired, + + /* + * Get unreads posts onload + */ + loadUnreads: PropTypes.func.isRequired, + + /* + * Get posts using BEFORE_ID and AFTER_ID + */ + loadPosts: PropTypes.func.isRequired, + + /* + * Used for changing posts status of channel + * For notifying redux store when we hit bottom or top of posts + */ + changeChannelPostsStatus: PropTypes.func.isRequired, + + /* + * Used for notifying redux store on channel sync complete + */ + channelSyncCompleted: PropTypes.func.isRequired, + + /* + * Used to sync when channel is out of sync because of socket disconnect + */ + syncChannelPosts: PropTypes.func.isRequired, + + /* + * Used to set mobile view on resize + */ + checkAndSetMobileView: PropTypes.func.isRequired, + + }).isRequired, + } + + constructor(props) { + super(props); + this.state = { + newerPosts: { + loading: false, + allLoaded: (props.channelPostsStatus && props.channelPostsStatus.atEnd) || false, + }, + olderPosts: { + loading: false, + allLoaded: PostView.oldestMessageLoadedInView(props), + }, + posts: this.props.posts, + }; + } + + componentDidMount() { + if (this.shouldLoadPosts(this.props)) { + this.postsOnLoad(this.props.channelId); + } + if (!this.props.channelSyncStatus && this.props.channelPostsStatus) { + this.syncChannelPosts(this.props); + } + } + + static getDerivedStateFromProps(nextProps, prevState) { + if (prevState.posts && nextProps.posts && (prevState.posts.length !== nextProps.posts.length)) { + if (PostView.oldestMessageLoadedInView(nextProps) !== prevState.olderPosts.allLoaded) { + return { + olderPosts: { + loading: false, + allLoaded: PostView.oldestMessageLoadedInView(nextProps), + }, + posts: nextProps.posts, + }; + } + return { + posts: nextProps.posts, + }; + } + return null; + } + + componentDidUpdate(prevProps) { + if (this.props.socketStatus && this.props.socketStatus.connected && !prevProps.socketStatus.connected) { + this.syncChannelPosts(this.props); + } + } + + shouldLoadPosts(props) { + if (!props.channelPostsStatus || !props.channelPostsStatus.atEnd || props.focusedPostId) { + return true; + } + + return false; + } + + syncChannelPosts = ({channelId, channelPostsStatus, socketStatus, posts}) => { + this.props.actions.syncChannelPosts({ + channelId, + channelPostsStatus, + posts, + lastDisconnectAt: socketStatus.lastDisconnectAt, + }); + } + + static oldestMessageLoadedInView({postIdsInCurrentChannel, posts, channelPostsStatus}) { + if (channelPostsStatus && channelPostsStatus.atStart) { + if (postIdsInCurrentChannel && postIdsInCurrentChannel[postIdsInCurrentChannel.length - 1] === posts[posts.length - 1].id) { + return true; + } + } + return false; + } + + postsOnLoad = async (channelId) => { + if (this.props.focusedPostId) { + await this.loadPermalinkPosts(channelId); + } else { + await this.loadUnreadPosts(channelId); + this.props.actions.channelSyncCompleted(channelId); + } + } + + setLoadingPosts = (type) => { + this.setState({ + [type]: { + ...this.state[type], + loading: true, + }, + }); + } + + callLoadPosts = async (channelId, postId, type) => { + let newState = {}; + const {moreToLoad, error} = await this.props.actions.loadPosts({ + channelId, + postId, + type, + }); + + if (type === PostRequestTypes.BEFORE_ID) { + newState = { + olderPosts: { + loading: false, + allLoaded: !moreToLoad, + }, + }; + this.changeChannelPostsStatus({atStart: !moreToLoad}); + } else { + newState = { + newerPosts: { + loading: false, + allLoaded: !moreToLoad, + }, + }; + this.changeChannelPostsStatus({atEnd: !moreToLoad}); + } + + this.setState(newState); + return {moreToLoad, error}; + } + + loadPermalinkPosts = (channelId) => { + const getPostThread = this.props.actions.getPostThread(this.props.focusedPostId, false); + const afterPosts = this.callLoadPosts(channelId, this.props.focusedPostId, PostRequestTypes.AFTER_ID); + const beforePosts = this.callLoadPosts(channelId, this.props.focusedPostId, PostRequestTypes.BEFORE_ID); + return Promise.all([ + beforePosts, + afterPosts, + getPostThread, + ]); + } + + loadUnreadPosts = async (channelId) => { + let atLatestMessage = false; + let atOldestmessage = false; + + const {data} = await this.props.actions.loadUnreads(channelId); + + // API returns 2*POSTS_PER_PAGE and if it less than 1*POSTS_PER_PAGE then we loaded all the posts. + if (data && Object.keys(data.posts).length < POSTS_PER_PAGE) { + atLatestMessage = true; + atOldestmessage = true; + } else { + const postsArray = data.order.map((id) => data.posts[id]); + const unreadCount = this.getUnreadPostsCount(postsArray, this.props.currentUserId); + if (unreadCount < POSTS_PER_PAGE) { + atLatestMessage = true; + } + } + + this.setState({ + olderPosts: { + loading: false, + allLoaded: atOldestmessage, + }, + newerPosts: { + loading: false, + allLoaded: atLatestMessage, + }, + }); + + this.changeChannelPostsStatus({atEnd: atLatestMessage}); + this.changeChannelPostsStatus({atStart: atOldestmessage}); + } + + changeChannelPostsStatus = (status) => { + this.props.actions.changeChannelPostsStatus({ + channelId: this.props.channelId, + ...status, + }); + } + + getPostsBefore = () => { + this.setLoadingPosts('olderPosts'); + const oldestPostId = getOldestPostIdFromPosts(this.props.posts); + return this.callLoadPosts(this.props.channelId, oldestPostId, PostRequestTypes.BEFORE_ID); + } + + getPostsAfter = () => { + this.setLoadingPosts('newerPosts'); + const newestMessageId = getNewestPostIdFromPosts(this.props.posts); + return this.callLoadPosts(this.props.channelId, newestMessageId, PostRequestTypes.AFTER_ID); + } + + getUnreadPostsCount = (posts, currentUserId) => { + //This can be different than the unreadCount on the sidebar as sytem messages + //are not considered for the count. + return posts.reduce((count, post) => { + if (post.create_at > this.props.lastViewedAt && + post.user_id !== currentUserId && + post.state !== Constants.POST_DELETED) { + return count + 1; + } + return count; + }, 0); + } + + render() { + const posts = this.props.posts; + let postList; + + if (!posts) { + return ( +
+ +
+ ); + } + + if (posts.length) { + postList = ( + + ); + } + return ( +
+
+
+
+ {postList} +
+
+
+
+ ); + } +} diff --git a/components/post_view/post_view.test.jsx b/components/post_view/post_view.test.jsx new file mode 100644 index 000000000000..425bd5d5ac00 --- /dev/null +++ b/components/post_view/post_view.test.jsx @@ -0,0 +1,278 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import {PostRequestTypes} from 'utils/constants.jsx'; + +import PostView from './post_view'; + +function emptyFunction() {} //eslint-disable-line no-empty-function + +const mockApiAscSuccess = async () => { + return { + data: {posts: {}, order: []}, + }; +}; + +const loadPostsMock = async () => { + return { + moreToLoad: true, + }; +}; + +const actionsProp = { + getPostThread: emptyFunction, + loadUnreads: mockApiAscSuccess, + loadPosts: loadPostsMock, + changeChannelPostsStatus: jest.fn(), + syncChannelPosts: jest.fn(), + channelSyncCompleted: emptyFunction, + checkAndSetMobileView: emptyFunction, +}; + +let channelPostsStatus; +const lastViewedAt = 1532345226632; +const channelId = 'fake-id'; + +const createFakePosts = (num) => { + const posts = []; + for (let i = 1; i <= num; i++) { + posts.push({ + id: `1234${i}`, + user_id: 'someone', + create_at: 1532345226631, // 1 less than lastViewedAt + }); + } + + return posts; +}; + +describe('components/post_view/post_list', () => { + it('snapshot for loading when there are no posts', () => { + const emptyPostList = undefined; + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('snapshot with couple of posts', () => { + const postList = createFakePosts(2); + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('init snapshot for max postVisibility', async () => { + const emptyPostList = []; + + const channelPostsStatusObj = { + atEnd: false, + atStart: false, + }; + + const wrapper = shallow( + + ); + + await actionsProp.loadUnreads(); + await wrapper.instance().postsOnLoad(); + wrapper.setState({olderPosts: {loading: false, allLoaded: false}, newerPosts: {loading: false, allLoaded: false}}); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); + }); + + it('Should call postsOnLoad', async () => { + const emptyPostList = []; + + const wrapper = shallow( + + ); + + await actionsProp.loadUnreads(); + await wrapper.instance().postsOnLoad(); + wrapper.update(); + expect(actionsProp.changeChannelPostsStatus).toHaveBeenCalledWith({channelId, atEnd: true}); + expect(actionsProp.changeChannelPostsStatus).toHaveBeenCalledWith({channelId, atStart: true}); + expect(wrapper).toMatchSnapshot(); + }); + + it('Should call for before and afterPosts', async () => { + const wrapper = shallow( + + ); + + wrapper.instance().getPostsBefore('1234'); + expect(wrapper.state().olderPosts.loading).toEqual(true); + await actionsProp.loadPosts(); + expect(actionsProp.changeChannelPostsStatus).toHaveBeenCalledWith({channelId, atStart: false}); + expect(wrapper.state().olderPosts.loading).toEqual(false); + + wrapper.instance().getPostsAfter('1234'); + expect(wrapper.state().newerPosts.loading).toEqual(true); + await actionsProp.loadPosts(); + expect(actionsProp.changeChannelPostsStatus).toHaveBeenCalledWith({channelId, atEnd: false}); + expect(wrapper.state().newerPosts.loading).toEqual(false); + }); + + it('Should handle changeChannelPostsStatus call for more than 30 posts onLoad with 0 unread', async () => { + const fakePosts = [ + ...createFakePosts(35), + { + id: '123456', + user_id: 'someone', + create_at: 1532345226634, + }, + ]; + + const wrapper = shallow( + + ); + + await actionsProp.loadUnreads(); + await wrapper.instance().postsOnLoad(); + wrapper.update(); + expect(actionsProp.changeChannelPostsStatus).toHaveBeenCalledWith({channelId, atEnd: true}); + expect(actionsProp.changeChannelPostsStatus).toHaveBeenCalledWith({channelId, atStart: true}); + }); + + it('should call sync posts if syncStatus is false and posts exist ', async () => { + const channelPostsStatusObj = { + atEnd: true, + atStart: false, + }; + + const postsObj = createFakePosts(3); + const channelSyncStatus = false; + const syncChannelPosts = jest.fn(); + const socketStatus = { + lastDisconnectAt: 1234, + }; + const channelSyncCompleted = jest.fn(); + + shallow( + + ); + expect(syncChannelPosts).toHaveBeenCalledTimes(1); + expect(syncChannelPosts).toHaveBeenCalledWith({ + channelId, + channelPostsStatus: channelPostsStatusObj, + posts: postsObj, + lastDisconnectAt: socketStatus.lastDisconnectAt, + }); + }); + + it('Should call APIs for permalink posts', () => { + const emptyPostList = []; + const focusedPostId = 'new'; + + const channelPostsStatusObj = {atEnd: false, atStart: false}; + const getPostThread = jest.fn(); + const loadPosts = jest.fn(); + + shallow( + + ); + expect(getPostThread).toHaveBeenCalledWith('new', false); + expect(loadPosts).toHaveBeenCalledWith({channelId, postId: 'new', type: PostRequestTypes.AFTER_ID}); + expect(loadPosts).toHaveBeenCalledWith({channelId, postId: 'new', type: PostRequestTypes.BEFORE_ID}); + }); + + it('Should sync posts on socket status change', () => { + const postsObj = createFakePosts(3); + const socketStatus = { + lastDisconnectAt: 1234, + connected: false, + }; + const channelPostsStatusObj = {atEnd: true, atStart: false}; + + const wrapper = shallow( + + ); + + wrapper.setProps({ + socketStatus: { + lastDisconnectAt: 1234, + connected: true, + }, + }); + expect(actionsProp.syncChannelPosts).toHaveBeenCalledWith({ + channelId, + channelPostsStatus: channelPostsStatusObj, + posts: postsObj, + lastDisconnectAt: socketStatus.lastDisconnectAt, + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 3995c8b8359b..b6d73d7a6494 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14406,7 +14406,7 @@ }, "serialize-error": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "resolved": "http://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", "integrity": "sha1-ULZ51WNc34Rme9yOWa9OW4HV9go=" }, "serialize-javascript": { diff --git a/reducers/views/channel.js b/reducers/views/channel.js index ae8842875185..e7c10195d75f 100644 --- a/reducers/views/channel.js +++ b/reducers/views/channel.js @@ -52,18 +52,6 @@ function lastChannelViewTime(state = {}, action) { } } -function loadingPosts(state = {}, action) { - switch (action.type) { - case ActionTypes.LOADING_POSTS: { - const nextState = {...state}; - nextState[action.channelId] = action.data; - return nextState; - } - default: - return state; - } -} - function focusedPostId(state = '', action) { switch (action.type) { case ActionTypes.RECEIVED_FOCUSED_POST: @@ -115,11 +103,57 @@ function keepChannelIdAsUnread(state = null, action) { } } +function channelPostsStatus(state = {}, action) { + switch (action.type) { + case ActionTypes.CHANNEL_POSTS_STATUS: { + const channelId = action.data.channelId; + if ((action.data).hasOwnProperty('atEnd')) { + return { + ...state, + [channelId]: { + ...state[channelId], + atEnd: action.data.atEnd, + }, + }; + } + return { + ...state, + [channelId]: { + ...state[channelId], + atStart: action.data.atStart, + }, + }; + } + default: + return state; + } +} + +function channelSyncStatus(state = {}, action) { + switch (action.type) { + case ActionTypes.ALL_CHANNEL_SYNC_STATUS: { + const nextState = action.data.channelIds.reduce((channelStatusObj, channelId) => ({ + ...channelStatusObj, + [channelId]: action.data.status, + }), {}); + return nextState; + } + case ActionTypes.CHANNEL_SYNC_STATUS: + return { + ...state, + [action.data]: true, + }; + default: + return state; + } +} + export default combineReducers({ postVisibility, lastChannelViewTime, - loadingPosts, focusedPostId, mobileView, keepChannelIdAsUnread, + channelPostsStatus, + channelSyncStatus, }); diff --git a/reducers/views/channel.test.js b/reducers/views/channel.test.js new file mode 100644 index 000000000000..1c2118bc88c9 --- /dev/null +++ b/reducers/views/channel.test.js @@ -0,0 +1,96 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import channelReducer from 'reducers/views/channel'; +import {ActionTypes} from 'utils/constants.jsx'; + +describe('Reducers.Channel', () => { + const initialState = { + channelSyncStatus: {}, + channelPostsStatus: {}, + focusedPostId: '', + keepChannelIdAsUnread: null, + lastChannelViewTime: {}, + mobileView: false, + postVisibility: {}, + }; + + test('Sync status true for a channel', () => { + const nextState = channelReducer( + initialState, + {type: ActionTypes.CHANNEL_SYNC_STATUS, data: 'channelId'} + ); + + expect(nextState).toEqual({ + ...initialState, + channelSyncStatus: { + channelId: true, + }, + }); + }); + + test('Sync status false for all channels when socket disconnets', () => { + const nextState = channelReducer( + initialState, + { + type: ActionTypes.ALL_CHANNEL_SYNC_STATUS, + data: { + channelIds: ['channel1', 'channel2'], + status: false, + }, + } + ); + + expect(nextState).toEqual({ + ...initialState, + channelSyncStatus: { + channel1: false, + channel2: false, + }, + }); + }); + + test('channelPostsStatus change for atEnd', () => { + const nextState = channelReducer( + initialState, + { + type: ActionTypes.CHANNEL_POSTS_STATUS, + data: { + channelId: 'channelId', + atEnd: false, + }, + } + ); + + expect(nextState).toEqual({ + ...initialState, + channelPostsStatus: { + channelId: { + atEnd: false, + }, + }, + }); + }); + + test('channelPostsStatus change for atStart', () => { + const nextState = channelReducer( + initialState, + { + type: ActionTypes.CHANNEL_POSTS_STATUS, + data: { + channelId: 'channelId', + atStart: false, + }, + } + ); + + expect(nextState).toEqual({ + ...initialState, + channelPostsStatus: { + channelId: { + atStart: false, + }, + }, + }); + }); +}); diff --git a/reducers/views/index.js b/reducers/views/index.js index f4c0432fea6b..22887ad06bcc 100644 --- a/reducers/views/index.js +++ b/reducers/views/index.js @@ -16,6 +16,7 @@ import search from './search'; import notice from './notice'; import system from './system'; import channelSelectorModal from './channel_selector_modal'; +import websocket from './websocket'; export default combineReducers({ admin, @@ -31,4 +32,5 @@ export default combineReducers({ notice, system, channelSelectorModal, + websocket, }); diff --git a/reducers/views/websocket.js b/reducers/views/websocket.js new file mode 100644 index 000000000000..803e8fdde1f8 --- /dev/null +++ b/reducers/views/websocket.js @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {GeneralTypes, UserTypes} from 'mattermost-redux/action_types'; + +function getInitialState() { + return { + connected: false, + lastConnectAt: 0, + lastDisconnectAt: 0, + }; +} + +export default function(state = getInitialState(), action) { + if (!state.connected && action.type === GeneralTypes.WEBSOCKET_SUCCESS) { + return { + ...state, + connected: true, + lastConnectAt: new Date().getTime(), + }; + } else if (state.connected && (action.type === GeneralTypes.WEBSOCKET_FAILURE || action.type === GeneralTypes.WEBSOCKET_CLOSED)) { + return { + ...state, + connected: false, + lastDisconnectAt: new Date().getTime(), + }; + } + + if (action.type === UserTypes.LOGOUT_SUCCESS) { + return getInitialState(); + } + + return state; +} diff --git a/reducers/views/websocket.test.js b/reducers/views/websocket.test.js new file mode 100644 index 000000000000..99875f6bbd2e --- /dev/null +++ b/reducers/views/websocket.test.js @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {GeneralTypes, UserTypes} from 'mattermost-redux/action_types'; + +import websocketReducer from 'reducers/views/websocket'; + +describe('Reducers.Channel', () => { + const constantDate = new Date('2018-01-01T12:00:00'); + + beforeAll(() => { + global.Date = class extends Date { + constructor() { + super(); + return constantDate; + } + }; + }); + + const initialState = { + connected: false, + lastConnectAt: 0, + lastDisconnectAt: 0, + }; + + test('init state', () => { + const nextState = websocketReducer(undefined, {}); + expect(nextState).toEqual(initialState); + }); + + test('websocket success should have connected flag true and timestamp saved', () => { + const nextState = websocketReducer(initialState, {type: GeneralTypes.WEBSOCKET_SUCCESS}); + + expect(nextState).toEqual({ + ...initialState, + connected: true, + lastConnectAt: 1514788200000, + }); + }); + + test('websocket success should have connected flag false and timestamp saved', () => { + const nextState = websocketReducer({...initialState, connected: true}, {type: GeneralTypes.WEBSOCKET_FAILURE}); + + expect(nextState).toEqual({ + ...initialState, + connected: false, + lastDisconnectAt: 1514788200000, + }); + }); + + test('reset to initstate on logout', () => { + const nextState = websocketReducer({...initialState, connected: true}, {type: UserTypes.LOGOUT_SUCCESS}); + expect(nextState).toEqual(initialState); + }); +}); diff --git a/sass/layout/_content.scss b/sass/layout/_content.scss index e1e013dbeeaa..b4d75011d5f2 100644 --- a/sass/layout/_content.scss +++ b/sass/layout/_content.scss @@ -91,4 +91,3 @@ padding-left: 15px; padding-right: 15px; } - diff --git a/sass/routes/_print.scss b/sass/routes/_print.scss index 43d9a0dba26a..16f5fd9e6d49 100644 --- a/sass/routes/_print.scss +++ b/sass/routes/_print.scss @@ -62,7 +62,6 @@ .post__body .img-div, .post-image__thumbnail, .post.post--system, - #archive-link-home, .channel-intro { display: none; } diff --git a/selectors/views/channel.js b/selectors/views/channel.js new file mode 100644 index 000000000000..c5e9acf017ac --- /dev/null +++ b/selectors/views/channel.js @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createSelector} from 'reselect'; + +export const makeGetChannelPostStatus = () => createSelector( + (state) => state.views.channel.channelPostsStatus, + (_, channelId) => (channelId), + (channelPostsStatus, channelId) => { + return channelPostsStatus[channelId]; + }, +); + +export const makeGetChannelSyncStatus = () => createSelector( + (state) => state.views.channel.channelSyncStatus, + (_, channelId) => (channelId), + (channelSyncStatus, channelId) => { + return channelSyncStatus[channelId]; + }, +); diff --git a/selectors/views/channel.test.js b/selectors/views/channel.test.js new file mode 100644 index 000000000000..9e69c60be922 --- /dev/null +++ b/selectors/views/channel.test.js @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {makeGetChannelPostStatus, makeGetChannelSyncStatus} from './channel'; + +describe('modals selector', () => { + const state = { + views: { + channel: { + channelSyncStatus: { + channelId: true, + }, + channelPostsStatus: { + channelId: { + atStart: true, + atEnd: true, + }, + }, + }, + }, + }; + + it('makeGetChannelPostStatus should return true for channelId', () => { + expect(makeGetChannelPostStatus()(state, 'channelId')).toEqual({ + atStart: true, + atEnd: true, + }); + }); + + it('makeGetChannelSyncStatus should return true for channelId', () => { + expect(makeGetChannelSyncStatus()(state, 'channelId')).toBeTruthy(); + }); +}); diff --git a/selectors/views/websocket.js b/selectors/views/websocket.js new file mode 100644 index 000000000000..4d35e8d6d2a1 --- /dev/null +++ b/selectors/views/websocket.js @@ -0,0 +1,6 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export function getSocketStatus(state) { + return state.views.websocket; +} diff --git a/utils/constants.jsx b/utils/constants.jsx index ce48e36d39ba..92376baad7a6 100644 --- a/utils/constants.jsx +++ b/utils/constants.jsx @@ -168,6 +168,10 @@ export const ActionTypes = keyMirror({ INCREMENT_WS_ERROR_COUNT: null, RESET_WS_ERROR_COUNT: null, + + CHANNEL_POSTS_STATUS: null, + CHANNEL_SYNC_STATUS: null, + ALL_CHANNEL_SYNC_STATUS: null, }); export const ModalIdentifiers = { @@ -279,6 +283,11 @@ export const PostTypes = { REMOVE_LINK_PREVIEW: 'remove_link_preview', }; +export const PostRequestTypes = keyMirror({ + BEFORE_ID: null, + AFTER_ID: null, +}); + export const StatTypes = keyMirror({ TOTAL_USERS: null, TOTAL_PUBLIC_CHANNELS: null,