diff --git a/Makefile b/Makefile index b411d9a7c948..ca6b72f73263 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ BUILD_SERVER_DIR = ../mattermost-server BUILD_WEBAPP_DIR = ../mattermost-webapp +MM_UTILITIES_DIR = ../mattermost-utilities EMOJI_TOOLS_DIR = ./build/emoji check-style: node_modules ## Checks JS file for ESLint confirmity @@ -14,6 +15,10 @@ test: node_modules ## Runs tests npm run test +i18n-extract: ## Extract strings for translation from the source code + @[[ -d $(MM_UTILITIES_DIR) ]] || echo "You must clone github.com/mattermost/mattermost-utilities repo in .. to use this command" + @[[ -d $(MM_UTILITIES_DIR) ]] && cd $(MM_UTILITIES_DIR) && npm install && npm run babel && node mmjstool/build/index.js i18n extract-webapp + node_modules: package.json package-lock.json @echo Getting dependencies using npm @@ -40,10 +45,10 @@ run: node_modules ## Runs app npm run run & -run-fullmap: node_modules ## Runs the app with the JS mapped to source (good for debugger) - @echo FULL SOURCE MAP Running mattermost Webapp for development FULL SOURCE MAP +run-fullmap: node_modules ## Legacy alias to run + @echo Running mattermost Webapp for development - npm run run-fullmap & + npm run run & stop: ## Stops webpack @echo Stopping changes watching diff --git a/NOTICE.txt b/NOTICE.txt index b3e96ceb836f..4f886389ed53 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -212,56 +212,6 @@ THE SOFTWARE. --- -## cypress - -This product contains 'cypress' by Cypress. - -Fast, easy and reliable testing for anything that runs in a browser. - -* HOMEPAGE: - * https://github.com/cypress-io/cypress - -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2016 Cypress.io, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## eslint-plugin-cypress - -This product contains 'eslint-plugin-cypress' by Cypress. - -An ESLint plugin for projects that use Cypress - -* HOMEPAGE: - * https://github.com/cypress-io/eslint-plugin-cypress - -* LICENSE: MIT - -(No license text is stated in the homepage.) - ---- - ## exif2css This product contains 'exif2css' by Anton. @@ -1984,16 +1934,16 @@ SOFTWARE. --- -## rebound-js +## rebound -This product contains 'rebound-js' by Facebook. +This product contains 'rebound' by Facebook. -Rebound is a simple library that models Spring dynamics for the purpose of driving physical animations. +A simple library for modeling spring dynamics * HOMEPAGE: * https://github.com/facebook/rebound-js -* LICENSE: BSD +* LICENSE: BSD-3-Clause BSD License diff --git a/actions/diagnostics_actions.jsx b/actions/diagnostics_actions.jsx index e0e8c3551715..567952ac34a6 100644 --- a/actions/diagnostics_actions.jsx +++ b/actions/diagnostics_actions.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Client4} from 'mattermost-redux/client'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import store from 'stores/redux_store.jsx'; @@ -16,23 +16,7 @@ const SUPPORTS_MEASURE_METHODS = isSupported([ ]); export function trackEvent(category, event, props) { - if (global.window && global.window.analytics) { - const properties = Object.assign({category, type: event, user_actual_id: getCurrentUserId(store.getState())}, props); - const options = { - context: { - ip: '0.0.0.0', - }, - page: { - path: '', - referrer: '', - search: '', - title: '', - url: '', - }, - anonymousId: '00000000000000000000000000', - }; - global.window.analytics.track('event', properties, options); - } + Client4.trackEvent(category, event, props); } /** diff --git a/actions/global_actions.jsx b/actions/global_actions.jsx index 411773e93402..4ca0744b92c0 100644 --- a/actions/global_actions.jsx +++ b/actions/global_actions.jsx @@ -2,31 +2,30 @@ // See LICENSE.txt for license information. import debounce from 'lodash/debounce'; +import {batchActions} from 'redux-batched-actions'; import { - getChannel, createDirectChannel, getChannelByNameAndTeamName, getChannelStats, getMyChannelMember, - joinChannel, markChannelAsRead, selectChannel, } from 'mattermost-redux/actions/channels'; -import {getPostThread} from 'mattermost-redux/actions/posts'; import {logout} from 'mattermost-redux/actions/users'; import {Client4} from 'mattermost-redux/client'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId, getTeam, getMyTeams, getMyTeamMember} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; -import {getCurrentChannelStats, getCurrentChannelId, getChannelByName} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentChannelStats, getCurrentChannelId, getChannelByName, getMyChannelMember as selectMyChannelMember} from 'mattermost-redux/selectors/entities/channels'; +import {ChannelTypes} from 'mattermost-redux/action_types'; import {browserHistory} from 'utils/browser_history'; -import {loadChannelsForCurrentUser} from 'actions/channel_actions.jsx'; import {handleNewPost} from 'actions/post_actions.jsx'; import {stopPeriodicStatusUpdates} from 'actions/status_actions.jsx'; -import {loadNewDMIfNeeded, loadNewGMIfNeeded, loadProfilesForSidebar} from 'actions/user_actions.jsx'; +import {loadProfilesForSidebar} from 'actions/user_actions.jsx'; import {closeRightHandSide, closeMenu as closeRhsMenu, updateRhsState} from 'actions/views/rhs'; +import {clearUserCookie} from 'actions/views/root'; import {close as closeLhs} from 'actions/views/lhs'; import * as WebsocketActions from 'actions/websocket_actions.jsx'; import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; @@ -37,7 +36,7 @@ import store from 'stores/redux_store.jsx'; import LocalStorageStore from 'stores/local_storage_store'; import WebSocketClient from 'client/web_websocket_client.jsx'; -import {ActionTypes, Constants, ErrorPageTypes, PostTypes, RHSStates} from 'utils/constants.jsx'; +import {ActionTypes, Constants, PostTypes, RHSStates} from 'utils/constants.jsx'; import EventTypes from 'utils/event_types.jsx'; import {filterAndSortTeamsByDisplayName} from 'utils/team_utils.jsx'; import * as Utils from 'utils/utils.jsx'; @@ -66,6 +65,7 @@ export function emitChannelClickEvent(channel) { const teamId = chan.team_id || getCurrentTeamId(state); const isRHSOpened = getIsRhsOpen(state); const isPinnedPostsShowing = getRhsState(state) === RHSStates.PIN; + const member = selectMyChannelMember(state, chan.id); getMyChannelMemberPromise.then(() => { dispatch(getChannelStats(chan.id)); @@ -89,11 +89,15 @@ export function emitChannelClickEvent(channel) { loadProfilesForSidebar(); - AppDispatcher.handleViewAction({ - type: ActionTypes.CLICK_CHANNEL, - id: chan.id, - team_id: teamId, - }); + dispatch(batchActions([{ + type: ChannelTypes.SELECT_CHANNEL, + data: chan.id, + }, { + type: ActionTypes.SELECT_CHANNEL_WITH_MEMBER, + data: chan.id, + channel: chan, + member, + }])); } if (channel.fake) { @@ -111,76 +115,10 @@ export function emitChannelClickEvent(channel) { } } -export async function doFocusPost(channelId, postId) { - dispatch(selectChannel(channelId)); - dispatch({ - type: ActionTypes.RECEIVED_FOCUSED_POST, - data: postId, - }); - - const member = getState().entities.channels.myMembers[channelId]; - if (member == null) { - await dispatch(joinChannel(getCurrentUserId(getState()), null, channelId)); - } - - dispatch(loadChannelsForCurrentUser()); - dispatch(getChannelStats(channelId)); -} - export function emitCloseRightHandSide() { dispatch(closeRightHandSide()); } -export async function emitPostFocusEvent(postId, returnTo = '') { - dispatch(loadChannelsForCurrentUser()); - const {data} = await dispatch(getPostThread(postId)); - - if (!data) { - browserHistory.replace(`/error?type=${ErrorPageTypes.PERMALINK_NOT_FOUND}&returnTo=${returnTo}`); - return; - } - - const channelId = data.posts[data.order[0]].channel_id; - let channel = getState().entities.channels.channels[channelId]; - const teamId = getCurrentTeamId(getState()); - - if (!channel) { - const {data: channelData} = await dispatch(getChannel(channelId)); - if (!channelData) { - browserHistory.replace(`/error?type=${ErrorPageTypes.PERMALINK_NOT_FOUND}&returnTo=${returnTo}`); - return; - } - channel = channelData; - } - - if (channel.team_id && channel.team_id !== teamId) { - browserHistory.replace(`/error?type=${ErrorPageTypes.PERMALINK_NOT_FOUND}&returnTo=${returnTo}`); - return; - } - - if (channel && channel.type === Constants.DM_CHANNEL) { - loadNewDMIfNeeded(channel.id); - } else if (channel && channel.type === Constants.GM_CHANNEL) { - loadNewGMIfNeeded(channel.id); - } - - await doFocusPost(channelId, postId, data); -} - -export function emitUserPostedEvent(post) { - AppDispatcher.handleServerAction({ - type: ActionTypes.CREATE_POST, - post, - }); -} - -export function emitUserCommentedEvent(post) { - AppDispatcher.handleServerAction({ - type: ActionTypes.CREATE_COMMENT, - post, - }); -} - export function toggleShortcutsModal() { AppDispatcher.handleViewAction({ type: ActionTypes.TOGGLE_SHORTCUTS_MODAL, @@ -234,76 +172,6 @@ export function showLeavePrivateChannelModal(channel) { }); } -export function emitSuggestionPretextChanged(suggestionId, pretext) { - AppDispatcher.handleViewAction({ - type: ActionTypes.SUGGESTION_PRETEXT_CHANGED, - id: suggestionId, - pretext, - }); -} - -export function emitSelectNextSuggestion(suggestionId) { - AppDispatcher.handleViewAction({ - type: ActionTypes.SUGGESTION_SELECT_NEXT, - id: suggestionId, - }); -} - -export function emitSelectPreviousSuggestion(suggestionId) { - AppDispatcher.handleViewAction({ - type: ActionTypes.SUGGESTION_SELECT_PREVIOUS, - id: suggestionId, - }); -} - -export function emitCompleteWordSuggestion(suggestionId, term = '') { - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.SUGGESTION_COMPLETE_WORD, - id: suggestionId, - term, - }); -} - -export function emitClearSuggestions(suggestionId) { - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.SUGGESTION_CLEAR_SUGGESTIONS, - id: suggestionId, - }); -} - -export function emitPreferenceChangedEvent(preference) { - AppDispatcher.handleServerAction({ - type: Constants.ActionTypes.RECEIVED_PREFERENCE, - preference, - }); - - if (addedNewDmUser(preference)) { - loadProfilesForSidebar(); - } -} - -export function emitPreferencesChangedEvent(preferences) { - AppDispatcher.handleServerAction({ - type: Constants.ActionTypes.RECEIVED_PREFERENCES, - preferences, - }); - - if (preferences.findIndex(addedNewDmUser) !== -1) { - loadProfilesForSidebar(); - } -} - -function addedNewDmUser(preference) { - return preference.category === Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW && preference.value === 'true'; -} - -export function emitPreferencesDeletedEvent(preferences) { - AppDispatcher.handleServerAction({ - type: Constants.ActionTypes.DELETED_PREFERENCES, - preferences, - }); -} - export function sendEphemeralPost(message, channelId, parentId) { const timestamp = Utils.getTimestamp(); const post = { @@ -364,15 +232,6 @@ export function emitLocalUserTypingEvent(channelId, parentPostId) { return dispatch(userTyping); } -export function emitRemoteUserTypingEvent(channelId, userId, postParentId) { - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.USER_TYPING, - channelId, - userId, - postParentId, - }); -} - export function emitUserLoggedOutEvent(redirectTo = '/', shouldSignalLogout = true, userAction = true) { // If the logout was intentional, discard knowledge about having previously been logged in. // This bit is otherwise used to detect session expirations on the login page. @@ -388,7 +247,9 @@ export function emitUserLoggedOutEvent(redirectTo = '/', shouldSignalLogout = tr BrowserStore.clear(); stopPeriodicStatusUpdates(); WebsocketActions.close(); - document.cookie = 'MMUSERID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + + clearUserCookie(); + browserHistory.push(redirectTo); }).catch(() => { browserHistory.push(redirectTo); @@ -396,9 +257,11 @@ export function emitUserLoggedOutEvent(redirectTo = '/', shouldSignalLogout = tr } export function toggleSideBarRightMenuAction() { - dispatch(closeRightHandSide()); - dispatch(closeLhs()); - dispatch(closeRhsMenu()); + return (doDispatch) => { + doDispatch(closeRightHandSide()); + doDispatch(closeLhs()); + doDispatch(closeRhsMenu()); + }; } export function emitBrowserFocus(focus) { diff --git a/actions/global_actions.test.js b/actions/global_actions.test.js index aa4a7ef9c3b0..30abb5acb70a 100644 --- a/actions/global_actions.test.js +++ b/actions/global_actions.test.js @@ -2,8 +2,19 @@ // See LICENSE.txt for license information. import {browserHistory} from 'utils/browser_history'; +import {closeRightHandSide, closeMenu as closeRhsMenu} from 'actions/views/rhs'; +import {close as closeLhs} from 'actions/views/lhs'; -import {redirectUserToDefaultTeam} from 'actions/global_actions.jsx'; +import {redirectUserToDefaultTeam, toggleSideBarRightMenuAction} from 'actions/global_actions.jsx'; + +jest.mock('actions/views/rhs', () => ({ + closeMenu: jest.fn(), + closeRightHandSide: jest.fn(), +})); + +jest.mock('actions/views/lhs', () => ({ + close: jest.fn(), +})); describe('actions/global_actions', () => { test('redirectUserToDefaultTeam', async () => { @@ -11,4 +22,12 @@ describe('actions/global_actions', () => { await redirectUserToDefaultTeam(); expect(browserHistory.push).toHaveBeenCalledWith('/select_team'); }); + + test('toggleSideBarRightMenuAction', () => { + const dispatchMock = () => {}; + toggleSideBarRightMenuAction()(dispatchMock); + expect(closeRhsMenu).toHaveBeenCalled(); + expect(closeRightHandSide).toHaveBeenCalled(); + expect(closeLhs).toHaveBeenCalled(); + }); }); diff --git a/actions/integration_actions.jsx b/actions/integration_actions.jsx index 59793c7958fb..38edd1e96965 100644 --- a/actions/integration_actions.jsx +++ b/actions/integration_actions.jsx @@ -11,9 +11,9 @@ import {openModal} from 'actions/views/modals'; import InteractiveDialog from 'components/interactive_dialog'; import store from 'stores/redux_store.jsx'; -import {Integrations} from 'utils/constants.jsx'; +const DEFAULT_PAGE_SIZE = 100; -export function loadIncomingHooksAndProfilesForTeam(teamId, page = 0, perPage = Integrations.PAGE_SIZE) { +export function loadIncomingHooksAndProfilesForTeam(teamId, page = 0, perPage = DEFAULT_PAGE_SIZE) { return async (dispatch) => { const {data} = await dispatch(IntegrationActions.getIncomingHooks(teamId, page, perPage)); if (data) { @@ -42,7 +42,7 @@ export function loadProfilesForIncomingHooks(hooks) { }; } -export function loadOutgoingHooksAndProfilesForTeam(teamId, page = 0, perPage = Integrations.PAGE_SIZE) { +export function loadOutgoingHooksAndProfilesForTeam(teamId, page = 0, perPage = DEFAULT_PAGE_SIZE) { return async (dispatch) => { const {data} = await dispatch(IntegrationActions.getOutgoingHooks('', teamId, page, perPage)); if (data) { @@ -100,6 +100,35 @@ export function loadProfilesForCommands(commands) { }; } +export function loadOAuthAppsAndProfiles(page = 0, perPage = DEFAULT_PAGE_SIZE) { + return async (dispatch) => { + const {data} = await dispatch(IntegrationActions.getOAuthApps(page, perPage)); + if (data) { + dispatch(loadProfilesForOAuthApps(data)); + } + }; +} + +export function loadProfilesForOAuthApps(apps) { + return async (dispatch, getState) => { + const state = getState(); + const profilesToLoad = {}; + for (let i = 0; i < apps.length; i++) { + const app = apps[i]; + if (!getUser(state, app.creator_id)) { + profilesToLoad[app.creator_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + dispatch(getProfilesByIds(list)); + }; +} + export function getYoutubeVideoInfo(googleKey, videoId, success, error) { request.get('https://www.googleapis.com/youtube/v3/videos'). query({part: 'snippet', id: videoId, key: googleKey}). diff --git a/actions/integration_actions.test.js b/actions/integration_actions.test.js index eb3ea70a672c..08c575da5e57 100644 --- a/actions/integration_actions.test.js +++ b/actions/integration_actions.test.js @@ -93,4 +93,24 @@ describe('actions/integration_actions', () => { expect(getProfilesByIds).not.toHaveBeenCalled(); }); }); + + describe('loadProfilesForOAuthApps', () => { + test('load profiles for apps including user we already have', () => { + const testStore = mockStore(initialState); + testStore.dispatch(Actions.loadProfilesForOAuthApps([{creator_id: 'current_user_id'}, {creator_id: 'user_id2'}])); + expect(getProfilesByIds).toHaveBeenCalledWith(expect.arrayContainingExactly(['user_id2'])); + }); + + test('load profiles for apps including only users we don\'t have', () => { + const testStore = mockStore(initialState); + testStore.dispatch(Actions.loadProfilesForOAuthApps([{creator_id: 'user_id1'}, {creator_id: 'user_id2'}])); + expect(getProfilesByIds).toHaveBeenCalledWith(expect.arrayContainingExactly(['user_id1', 'user_id2'])); + }); + + test('load profiles for empty apps', () => { + const testStore = mockStore(initialState); + testStore.dispatch(Actions.loadProfilesForOAuthApps([])); + expect(getProfilesByIds).not.toHaveBeenCalled(); + }); + }); }); \ No newline at end of file diff --git a/actions/post_actions.jsx b/actions/post_actions.jsx index 72a92ebced42..b6ef1c2e7ee7 100644 --- a/actions/post_actions.jsx +++ b/actions/post_actions.jsx @@ -15,7 +15,6 @@ import {addRecentEmoji} from 'actions/emoji_actions'; import * as StorageActions from 'actions/storage'; import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions.jsx'; import * as RhsActions from 'actions/views/rhs'; -import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import {isEmbedVisible} from 'selectors/posts'; import {getSelectedPostId, getRhsState} from 'selectors/rhs'; import { @@ -62,24 +61,7 @@ export function flagPost(postId) { const rhsState = getRhsState(state); if (rhsState === RHSStates.FLAG) { - const results = state.entities.search.results; - const index = results.indexOf(postId); - if (index === -1) { - const flaggedPost = PostSelectors.getPost(state, postId); - const posts = getPostsForIds(state, results).reduce((acc, post) => { - acc[post.id] = post; - return acc; - }, {}); - posts[flaggedPost.id] = flaggedPost; - - const newResults = [...results, postId]; - newResults.sort((a, b) => comparePosts(posts[a], posts[b])); - - dispatch({ - type: SearchTypes.RECEIVED_SEARCH_POSTS, - data: {posts, order: newResults}, - }); - } + addPostToSearchResults(postId, state, dispatch); } return {data: true}; @@ -93,19 +75,7 @@ export function unflagPost(postId) { const rhsState = getRhsState(state); if (rhsState === RHSStates.FLAG) { - let results = state.entities.search.results; - const index = results.indexOf(postId); - if (index > -1) { - results = [...results]; - results.splice(index, 1); - - const posts = getPostsForIds(state, results); - - dispatch({ - type: SearchTypes.RECEIVED_SEARCH_POSTS, - data: {posts, order: results}, - }); - } + removePostFromSearchResults(postId, state, dispatch); } return {data: true}; @@ -220,25 +190,64 @@ export function searchForTerm(term) { }; } +function addPostToSearchResults(postId, state, dispatch) { + const results = state.entities.search.results; + const index = results.indexOf(postId); + if (index === -1) { + const newPost = PostSelectors.getPost(state, postId); + const posts = getPostsForIds(state, results).reduce((acc, post) => { + acc[post.id] = post; + return acc; + }, {}); + posts[newPost.id] = newPost; + + const newResults = [...results, postId]; + newResults.sort((a, b) => comparePosts(posts[a], posts[b])); + + dispatch({ + type: SearchTypes.RECEIVED_SEARCH_POSTS, + data: {posts, order: newResults}, + }); + } +} + +function removePostFromSearchResults(postId, state, dispatch) { + let results = state.entities.search.results; + const index = results.indexOf(postId); + if (index > -1) { + results = [...results]; + results.splice(index, 1); + + const posts = getPostsForIds(state, results); + + dispatch({ + type: SearchTypes.RECEIVED_SEARCH_POSTS, + data: {posts, order: results}, + }); + } +} + export function pinPost(postId) { - return async (dispatch) => { + return async (dispatch, getState) => { await dispatch(PostActions.pinPost(postId)); + const state = getState(); + const rhsState = getRhsState(state); - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POST_PINNED, - postId, - }); + if (rhsState === RHSStates.PIN) { + addPostToSearchResults(postId, state, dispatch); + } }; } export function unpinPost(postId) { - return async (dispatch) => { + return async (dispatch, getState) => { await dispatch(PostActions.unpinPost(postId)); + const state = getState(); + const rhsState = getRhsState(state); - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POST_UNPINNED, - postId, - }); + if (rhsState === RHSStates.PIN) { + removePostFromSearchResults(postId, state, dispatch); + } }; } @@ -305,12 +314,6 @@ export function deleteAndRemovePost(post) { data: post, }); - // Needed for search store - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.REMOVE_POST, - post, - }); - return {data: true}; }; } diff --git a/actions/post_actions.test.js b/actions/post_actions.test.js index cfbd6b6e22ab..4112032d3bdf 100644 --- a/actions/post_actions.test.js +++ b/actions/post_actions.test.js @@ -5,9 +5,10 @@ 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 Actions from 'actions/post_actions'; -import {Constants, ActionTypes} from 'utils/constants'; +import {Constants, ActionTypes, RHSStates} from 'utils/constants'; const mockStore = configureStore([thunk]); @@ -17,6 +18,10 @@ 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}), + 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}), })); jest.mock('actions/emoji_actions', () => ({ @@ -35,12 +40,14 @@ jest.mock('utils/user_agent', () => ({ isIosClassic: jest.fn().mockReturnValueOnce(true).mockReturnValue(false), })); +const POST_CREATED_TIME = Date.now(); const RECEIVED_POSTS = { channelId: 'current_channel_id', - data: {order: [], posts: {new_post_id: {channel_id: 'current_channel_id', id: 'new_post_id', message: 'new message', type: ''}}}, + data: {order: [], posts: {new_post_id: {channel_id: 'current_channel_id', id: 'new_post_id', message: 'new message', type: '', user_id: 'some_user_id', create_at: POST_CREATED_TIME}}}, type: 'RECEIVED_POSTS', }; 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) { const receivedPosts = {...RECEIVED_POSTS}; @@ -144,6 +151,7 @@ describe('Actions.Posts', () => { }, }, emojis: {customEmoji: {}}, + search: {results: []}, }, views: { posts: { @@ -159,11 +167,18 @@ describe('Actions.Posts', () => { test('handleNewPost', async () => { const testStore = await mockStore(initialState); - const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL}; + const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL, user_id: 'some_user_id', create_at: POST_CREATED_TIME}; const msg = {data: {team_id: 'team_id', mentions: ['current_user_id']}}; await testStore.dispatch(Actions.handleNewPost(newPost, msg)); - expect(testStore.getActions()).toEqual([INCREASED_POST_VISIBILITY, getReceivedPosts(newPost)]); + expect(testStore.getActions()).toEqual([ + INCREASED_POST_VISIBILITY, + { + meta: {batch: true}, + payload: [getReceivedPosts(newPost), STOP_TYPING], + type: 'BATCHING_REDUCER.BATCH', + }, + ]); }); test('setEditingPost', async () => { @@ -306,4 +321,52 @@ describe('Actions.Posts', () => { {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, ]); }); + + test('flagPost', async () => { + const testStore = await mockStore({...initialState, views: {rhs: {rhsState: RHSStates.FLAG}}}); + + const post = testStore.getState().entities.posts.posts[latestPost.id]; + + await testStore.dispatch(Actions.flagPost(post.id)); + expect(testStore.getActions()).toEqual([ + {args: [post.id], type: 'MOCK_FLAG_POST'}, + {data: {posts: {[post.id]: post}, order: [post.id]}, type: SearchTypes.RECEIVED_SEARCH_POSTS}, + ]); + }); + + test('unflagPost', async () => { + const testStore = await mockStore({views: {rhs: {rhsState: RHSStates.FLAG}}, entities: {...initialState.entities, search: {results: [latestPost.id]}}}); + + const post = testStore.getState().entities.posts.posts[latestPost.id]; + + await testStore.dispatch(Actions.unflagPost(post.id)); + expect(testStore.getActions()).toEqual([ + {args: [post.id], type: 'MOCK_UNFLAG_POST'}, + {data: {posts: [], order: []}, type: SearchTypes.RECEIVED_SEARCH_POSTS}, + ]); + }); + + test('pinPost', async () => { + const testStore = await mockStore({...initialState, views: {rhs: {rhsState: RHSStates.PIN}}}); + + const post = testStore.getState().entities.posts.posts[latestPost.id]; + + await testStore.dispatch(Actions.pinPost(post.id)); + expect(testStore.getActions()).toEqual([ + {args: [post.id], type: 'MOCK_PIN_POST'}, + {data: {posts: {[post.id]: post}, order: [post.id]}, type: SearchTypes.RECEIVED_SEARCH_POSTS}, + ]); + }); + + test('unpinPost', async () => { + const testStore = await mockStore({views: {rhs: {rhsState: RHSStates.PIN}}, entities: {...initialState.entities, search: {results: [latestPost.id]}}}); + + const post = testStore.getState().entities.posts.posts[latestPost.id]; + + await testStore.dispatch(Actions.unpinPost(post.id)); + expect(testStore.getActions()).toEqual([ + {args: [post.id], type: 'MOCK_UNPIN_POST'}, + {data: {posts: [], order: []}, type: SearchTypes.RECEIVED_SEARCH_POSTS}, + ]); + }); }); diff --git a/actions/post_utils.js b/actions/post_utils.js index 0a7286c6ac7f..c43e9ae635b9 100644 --- a/actions/post_utils.js +++ b/actions/post_utils.js @@ -1,6 +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} from 'mattermost-redux/action_types'; import { markChannelAsRead, @@ -8,6 +9,7 @@ import { markChannelAsViewed, } from 'mattermost-redux/actions/channels'; import * as PostActions from 'mattermost-redux/actions/posts'; +import {WebsocketEvents} from 'mattermost-redux/constants'; import * as PostSelectors from 'mattermost-redux/selectors/entities/posts'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; @@ -52,7 +54,7 @@ export function lastPostActions(post, websocketMessageProps) { } // Need manual dispatch to remove pending post - dispatch({ + const actions = [{ type: PostTypes.RECEIVED_POSTS, data: { order: [], @@ -61,7 +63,16 @@ export function lastPostActions(post, websocketMessageProps) { }, }, channelId: post.channel_id, - }); + }, { + type: WebsocketEvents.STOP_TYPING, + data: { + id: post.channel_id + post.root_id, + userId: post.user_id, + now: post.create_at, + }, + }]; + + dispatch(batchActions(actions)); // Still needed to update unreads diff --git a/actions/post_utils.test.js b/actions/post_utils.test.js index 1a9e5dd3486c..ef10c2d9464b 100644 --- a/actions/post_utils.test.js +++ b/actions/post_utils.test.js @@ -30,12 +30,14 @@ const MARK_CHANNEL_AS_VIEWED = { args: ['current_channel_id'], type: 'MOCK_MARK_CHANNEL_AS_VIEWED', }; +const POST_CREATED_TIME = Date.now(); const RECEIVED_POSTS = { channelId: 'current_channel_id', - data: {order: [], posts: {new_post_id: {channel_id: 'current_channel_id', id: 'new_post_id', message: 'new message', type: ''}}}, + data: {order: [], posts: {new_post_id: {channel_id: 'current_channel_id', id: 'new_post_id', message: 'new message', type: '', user_id: 'some_user_id', create_at: POST_CREATED_TIME}}}, type: 'RECEIVED_POSTS', }; 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) { const receivedPosts = {...RECEIVED_POSTS}; @@ -98,20 +100,34 @@ describe('actions/post_utils', () => { test('completePostReceive', async () => { const testStore = await mockStore(initialState); - const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL}; + const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL, user_id: 'some_user_id', create_at: POST_CREATED_TIME}; const websocketProps = {team_id: 'team_id', mentions: ['current_user_id']}; await testStore.dispatch(PostActionsUtils.completePostReceive(newPost, websocketProps)); - expect(testStore.getActions()).toEqual([INCREASED_POST_VISIBILITY, getReceivedPosts(newPost)]); + expect(testStore.getActions()).toEqual([ + INCREASED_POST_VISIBILITY, + { + meta: {batch: true}, + payload: [getReceivedPosts(newPost), STOP_TYPING], + type: 'BATCHING_REDUCER.BATCH', + }, + ]); }); test('lastPostActions', async () => { const testStore = await mockStore(initialState); - const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL}; + const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL, user_id: 'some_user_id', create_at: POST_CREATED_TIME}; const websocketProps = {team_id: 'team_id', mentions: ['current_user_id']}; await testStore.dispatch(PostActionsUtils.lastPostActions(newPost, websocketProps)); - expect(testStore.getActions()).toEqual([INCREASED_POST_VISIBILITY, getReceivedPosts(newPost)]); + expect(testStore.getActions()).toEqual([ + INCREASED_POST_VISIBILITY, + { + meta: {batch: true}, + payload: [getReceivedPosts(newPost), STOP_TYPING], + type: 'BATCHING_REDUCER.BATCH', + }, + ]); }); test('setChannelReadAndView', async () => { diff --git a/actions/team_actions.jsx b/actions/team_actions.jsx index 868245a7a941..046ff4b1b465 100644 --- a/actions/team_actions.jsx +++ b/actions/team_actions.jsx @@ -9,7 +9,7 @@ import {getUser} from 'mattermost-redux/actions/users'; import {browserHistory} from 'utils/browser_history'; -export function removeUserFromTeam(teamId, userId) { +export function removeUserFromTeamAndGetStats(teamId, userId) { return async (dispatch, getState) => { const response = await dispatch(TeamActions.removeUserFromTeam(teamId, userId)); dispatch(getUser(userId)); diff --git a/tests/redux/actions/team_actions.test.js b/actions/team_actions.test.js similarity index 95% rename from tests/redux/actions/team_actions.test.js rename to actions/team_actions.test.js index ec083791095d..e37b53289495 100644 --- a/tests/redux/actions/team_actions.test.js +++ b/actions/team_actions.test.js @@ -88,8 +88,8 @@ describe('Actions.Team', () => { expect(TeamActions.addUsersToTeam).toHaveBeenCalledWith('teamId', ['123', '1234']); }); - test('removeUserFromTeam', async () => { - await testStore.dispatch(Actions.removeUserFromTeam('teamId', '123')); + test('removeUserFromTeamAndGetStats', async () => { + await testStore.dispatch(Actions.removeUserFromTeamAndGetStats('teamId', '123')); expect(userActions.getUser).toHaveBeenCalledWith('123'); expect(TeamActions.getTeamStats).toHaveBeenCalledWith('teamId'); expect(channelActions.getChannelStats).toHaveBeenCalledWith('currentChannelId'); diff --git a/actions/user_actions.jsx b/actions/user_actions.jsx index cab8b8ab7bd4..2081d05a73a0 100644 --- a/actions/user_actions.jsx +++ b/actions/user_actions.jsx @@ -39,45 +39,59 @@ export async function switchFromLdapToEmail(email, password, token, ldapPassword } } -export async function loadProfilesAndTeamMembers(page, perPage, teamId = getCurrentTeamId(getState()), success) { - const {data} = await UserActions.getProfilesInTeam(teamId, page, perPage)(dispatch, getState); - loadTeamMembersForProfilesList(data, teamId, success); - dispatch(loadStatusesForProfilesList(data)); -} +export function loadProfilesAndTeamMembers(page, perPage, teamId) { + return async (doDispatch, doGetState) => { + const newTeamId = teamId || getCurrentTeamId(doGetState()); + const {data} = await doDispatch(UserActions.getProfilesInTeam(newTeamId, page, perPage)); + if (data) { + doDispatch(loadTeamMembersForProfilesList(data, newTeamId)); + doDispatch(loadStatusesForProfilesList(data)); + } -export async function loadProfilesAndTeamMembersAndChannelMembers(page, perPage, teamId = getCurrentTeamId(getState()), channelId = getCurrentChannelId(getState()), success, error) { - const {data} = await UserActions.getProfilesInChannel(channelId, page, perPage)(dispatch, getState); + return {data: true}; + }; +} - loadTeamMembersForProfilesList( - data, - teamId, - () => { - loadChannelMembersForProfilesList(data, channelId, success, error); - dispatch(loadStatusesForProfilesList(data)); +export function loadProfilesAndTeamMembersAndChannelMembers(page, perPage, teamId, channelId) { + return async (doDispatch, doGetState) => { + const state = doGetState(); + const teamIdParam = teamId || getCurrentTeamId(state); + const channelIdParam = channelId || getCurrentChannelId(state); + const {data} = await doDispatch(UserActions.getProfilesInChannel(channelIdParam, page, perPage)); + if (data) { + const {data: listData} = await doDispatch(loadTeamMembersForProfilesList(data, teamIdParam)); + if (listData) { + doDispatch(loadChannelMembersForProfilesList(data, channelIdParam)); + doDispatch(loadStatusesForProfilesList(data)); + } } - ); -} -export function loadTeamMembersForProfilesList(profiles, teamId = getCurrentTeamId(getState()), success, error) { - const state = getState(); - const membersToLoad = {}; - for (let i = 0; i < profiles.length; i++) { - const pid = profiles[i].id; + return {data: true}; + }; +} - if (!getTeamMember(state, teamId, pid)) { - membersToLoad[pid] = true; +export function loadTeamMembersForProfilesList(profiles, teamId) { + return async (doDispatch, doGetState) => { + const state = doGetState(); + const teamIdParam = teamId || getCurrentTeamId(state); + const membersToLoad = {}; + for (let i = 0; i < profiles.length; i++) { + const pid = profiles[i].id; + + if (!getTeamMember(state, teamIdParam, pid)) { + membersToLoad[pid] = true; + } } - } - const list = Object.keys(membersToLoad); - if (list.length === 0) { - if (success) { - success({}); + const userIdsToLoad = Object.keys(membersToLoad); + if (userIdsToLoad.length === 0) { + return {data: true}; } - return; - } - loadTeamMembersForProfiles(list, teamId, success, error); + await doDispatch(getTeamMembersByIds(teamIdParam, userIdsToLoad)); + + return {data: true}; + }; } export async function loadProfilesWithoutTeam(page, perPage, success) { @@ -89,78 +103,42 @@ export async function loadProfilesWithoutTeam(page, perPage, success) { } } -async function loadTeamMembersForProfiles(userIds, teamId, success, error) { - const {data, error: err} = await getTeamMembersByIds(teamId, userIds)(dispatch, getState); - - if (data && success) { - success(data); - } else if (err && error) { - error({id: err.server_error_id, ...err}); - } -} - -export function loadChannelMembersForProfilesMap(profiles, channelId = getCurrentChannelId(getState()), success, error) { - const membersToLoad = {}; - for (const pid in profiles) { - if (!profiles.hasOwnProperty(pid)) { - continue; - } - - const members = getChannelMembersInChannels(getState())[channelId]; - if (!members || !members[pid]) { - membersToLoad[pid] = true; - } - } - - const list = Object.keys(membersToLoad); - if (list.length === 0) { - if (success) { - success({}); - } - return; - } - - dispatch(getChannelMembersByIds(channelId, list)).then((result) => { - if (result.error) { - error(result.error); - } else { - success(result.data); +export function loadTeamMembersAndChannelMembersForProfilesList(profiles, teamId, channelId) { + return async (doDispatch, doGetState) => { + const state = doGetState(); + const teamIdParam = teamId || getCurrentTeamId(state); + const channelIdParam = channelId || getCurrentChannelId(state); + const {data} = await doDispatch(loadTeamMembersForProfilesList(profiles, teamIdParam)); + if (data) { + doDispatch(loadChannelMembersForProfilesList(profiles, channelIdParam)); } - }); -} -export function loadTeamMembersAndChannelMembersForProfilesList(profiles, teamId = getCurrentTeamId(getState()), channelId = getCurrentChannelId(getState()), success, error) { - loadTeamMembersForProfilesList(profiles, teamId, () => { - loadChannelMembersForProfilesList(profiles, channelId, success, error); - }, error); + return {data: true}; + }; } -export function loadChannelMembersForProfilesList(profiles, channelId = getCurrentChannelId(getState()), success, error) { - const membersToLoad = {}; - for (let i = 0; i < profiles.length; i++) { - const pid = profiles[i].id; - - const members = getChannelMembersInChannels(getState())[channelId]; - if (!members || !members[pid]) { - membersToLoad[pid] = true; +export function loadChannelMembersForProfilesList(profiles, channelId) { + return async (doDispatch, doGetState) => { + const state = doGetState(); + const channelIdParam = channelId || getCurrentChannelId(state); + const membersToLoad = {}; + for (let i = 0; i < profiles.length; i++) { + const pid = profiles[i].id; + + const members = getChannelMembersInChannels(state)[channelIdParam]; + if (!members || !members[pid]) { + membersToLoad[pid] = true; + } } - } - const list = Object.keys(membersToLoad); - if (list.length === 0) { - if (success) { - success({}); + const list = Object.keys(membersToLoad); + if (list.length === 0) { + return {data: true}; } - return; - } - dispatch(getChannelMembersByIds(channelId, list)).then((result) => { - if (result.error) { - error(result.error); - } else { - success(result.data); - } - }); + await doDispatch(getChannelMembersByIds(channelIdParam, list)); + return {data: true}; + }; } export async function loadNewDMIfNeeded(channelId) { @@ -229,8 +207,8 @@ export async function loadProfilesForGM() { continue; } - const userIds = userIdsInChannels[channel.id] || []; - if (userIds.length >= Constants.MIN_USERS_IN_GM) { + const userIds = userIdsInChannels[channel.id] || new Set(); + if (userIds.size >= Constants.MIN_USERS_IN_GM) { continue; } @@ -331,7 +309,8 @@ function onThemeSaved(teamId, onSuccess) { const toDelete = []; for (const themePreference of themePreferences) { - if (themePreference.name === '' || themePreference.name === teamId) { + const name = themePreference.name; + if (name === '' || name === teamId) { continue; } diff --git a/actions/user_actions.test.js b/actions/user_actions.test.js new file mode 100644 index 000000000000..bd6366ce6962 --- /dev/null +++ b/actions/user_actions.test.js @@ -0,0 +1,177 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import thunk from 'redux-thunk'; +import configureStore from 'redux-mock-store'; + +import * as UserActions from 'actions/user_actions'; + +const mockStore = configureStore([thunk]); + +jest.mock('mattermost-redux/actions/users', () => { + const original = require.requireActual('mattermost-redux/actions/users'); + return { + ...original, + getProfilesInTeam: (...args) => ({type: 'MOCK_GET_PROFILES_IN_TEAM', args}), + getProfilesInChannel: (...args) => ({type: 'MOCK_GET_PROFILES_IN_CHANNEL', args}), + }; +}); + +jest.mock('mattermost-redux/actions/teams', () => { + const original = require.requireActual('mattermost-redux/actions/teams'); + return { + ...original, + getTeamMembersByIds: (...args) => ({type: 'MOCK_GET_TEAM_MEMBERS_BY_IDS', args}), + }; +}); + +jest.mock('mattermost-redux/actions/channels', () => { + const original = require.requireActual('mattermost-redux/actions/channels'); + return { + ...original, + getChannelMembersByIds: (...args) => ({type: 'MOCK_GET_CHANNEL_MEMBERS_BY_IDS', args}), + }; +}); + +describe('Actions.User', () => { + const initialState = { + entities: { + channels: { + currentChannelId: 'current_channel_id', + myMembers: { + current_channel_id: { + channel_id: 'current_channel_id', + user_id: 'current_user_id', + roles: 'channel_role', + mention_count: 1, + msg_count: 9, + }, + }, + channels: { + current_channel_id: { + total_msg_count: 10, + team_id: 'team_id', + }, + }, + channelsInTeam: { + team_id: ['current_channel_id'], + }, + membersInChannel: { + current_channel_id: { + current_user_id: {id: 'current_user_id'}, + }, + }, + }, + teams: { + currentTeamId: 'team_id', + teams: { + team_id: { + id: 'team_id', + name: 'team-1', + displayName: 'Team 1', + }, + }, + myMembers: { + team_id: {roles: 'team_role'}, + }, + membersInTeam: { + team_id: { + current_user_id: {id: 'current_user_id'}, + }, + }, + }, + }, + }; + + test('loadProfilesAndTeamMembers', async () => { + const expectedActions = [{type: 'MOCK_GET_PROFILES_IN_TEAM', args: ['team_id', 0, 60]}]; + + let testStore = await mockStore({}); + await testStore.dispatch(UserActions.loadProfilesAndTeamMembers(0, 60, 'team_id')); + let actualActions = testStore.getActions(); + expect(actualActions[0].args).toEqual(expectedActions[0].args); + expect(actualActions[0].type).toEqual(expectedActions[0].type); + + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadProfilesAndTeamMembers(0, 60)); + actualActions = testStore.getActions(); + expect(actualActions[0].args).toEqual(expectedActions[0].args); + expect(actualActions[0].type).toEqual(expectedActions[0].type); + }); + + test('loadProfilesAndTeamMembersAndChannelMembers', async () => { + const expectedActions = [{type: 'MOCK_GET_PROFILES_IN_CHANNEL', args: ['current_channel_id', 0, 60]}]; + + let testStore = await mockStore({}); + await testStore.dispatch(UserActions.loadProfilesAndTeamMembersAndChannelMembers(0, 60, 'team_id', 'current_channel_id')); + let actualActions = testStore.getActions(); + expect(actualActions[0].args).toEqual(expectedActions[0].args); + expect(actualActions[0].type).toEqual(expectedActions[0].type); + + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadProfilesAndTeamMembersAndChannelMembers(0, 60)); + actualActions = testStore.getActions(); + expect(actualActions[0].args).toEqual(expectedActions[0].args); + expect(actualActions[0].type).toEqual(expectedActions[0].type); + }); + + test('loadTeamMembersForProfilesList', async () => { + const expectedActions = [{args: ['team_id', ['other_user_id']], type: 'MOCK_GET_TEAM_MEMBERS_BY_IDS'}]; + + // should call getTeamMembersByIds since 'other_user_id' is not loaded yet + let testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([{id: 'other_user_id'}], 'team_id')); + expect(testStore.getActions()).toEqual(expectedActions); + + // should not call getTeamMembersByIds since 'current_user_id' is already loaded + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([{id: 'current_user_id'}], 'team_id')); + expect(testStore.getActions()).toEqual([]); + + // should not call getTeamMembersByIds since no or empty profile is passed + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([], 'team_id')); + expect(testStore.getActions()).toEqual([]); + }); + + test('loadTeamMembersAndChannelMembersForProfilesList', async () => { + const expectedActions = [ + {args: ['team_id', ['other_user_id']], type: 'MOCK_GET_TEAM_MEMBERS_BY_IDS'}, + {args: ['current_channel_id', ['other_user_id']], type: 'MOCK_GET_CHANNEL_MEMBERS_BY_IDS'}, + ]; + + // should call getTeamMembersByIds and getChannelMembersByIds since 'other_user_id' is not loaded yet + let testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersAndChannelMembersForProfilesList([{id: 'other_user_id'}], 'team_id', 'current_channel_id')); + expect(testStore.getActions()).toEqual(expectedActions); + + // should not call getTeamMembersByIds/getChannelMembersByIds since 'current_user_id' is already loaded + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([{id: 'current_user_id'}], 'team_id', 'current_channel_id')); + expect(testStore.getActions()).toEqual([]); + + // should not call getTeamMembersByIds/getChannelMembersByIds since no or empty profile is passed + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([], 'team_id', 'current_channel_id')); + expect(testStore.getActions()).toEqual([]); + }); + + test('loadChannelMembersForProfilesList', async () => { + const expectedActions = [{args: ['current_channel_id', ['other_user_id']], type: 'MOCK_GET_CHANNEL_MEMBERS_BY_IDS'}]; + + // should call getChannelMembersByIds since 'other_user_id' is not loaded yet + let testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadChannelMembersForProfilesList([{id: 'other_user_id'}], 'current_channel_id')); + expect(testStore.getActions()).toEqual(expectedActions); + + // should not call getChannelMembersByIds since 'current_user_id' is already loaded + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadChannelMembersForProfilesList([{id: 'current_user_id'}], 'current_channel_id')); + expect(testStore.getActions()).toEqual([]); + + // should not call getChannelMembersByIds since no or empty profile is passed + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadChannelMembersForProfilesList([], 'current_channel_id')); + expect(testStore.getActions()).toEqual([]); + }); +}); diff --git a/actions/views/channel.js b/actions/views/channel.js index cf761b9a4c57..6a24bd18a25a 100644 --- a/actions/views/channel.js +++ b/actions/views/channel.js @@ -1,10 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {leaveChannel as leaveChannelRedux, unfavoriteChannel} from 'mattermost-redux/actions/channels'; -import {getChannel, getChannelByName, getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {leaveChannel as leaveChannelRedux, joinChannel, unfavoriteChannel} from 'mattermost-redux/actions/channels'; +import {getChannel, getChannelByName, getCurrentChannel, getDefaultChannel} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentRelativeTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; -import {getUserByUsername} from 'mattermost-redux/selectors/entities/users'; +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 {autocompleteUsers} from 'mattermost-redux/actions/users'; @@ -13,7 +13,7 @@ import {openDirectChannelToUserId} from 'actions/channel_actions.jsx'; import {getLastViewedChannelName} from 'selectors/local_storage'; import {browserHistory} from 'utils/browser_history'; -import {ActionTypes} from 'utils/constants.jsx'; +import {Constants, ActionTypes} from 'utils/constants.jsx'; import {isMobile} from 'utils/utils.jsx'; export function checkAndSetMobileView() { @@ -28,8 +28,14 @@ export function checkAndSetMobileView() { export function goToLastViewedChannel() { return async (dispatch, getState) => { const state = getState(); - const lastViewedChannel = getChannelByName(state, getLastViewedChannelName(state)); - return dispatch(switchToChannel(lastViewedChannel)); + const currentChannel = getCurrentChannel(state); + let channelToSwitchTo = getChannelByName(state, getLastViewedChannelName(state)); + + if (currentChannel.id === channelToSwitchTo.id) { + channelToSwitchTo = getDefaultChannel(state); + } + + return dispatch(switchToChannel(channelToSwitchTo)); }; } @@ -58,6 +64,9 @@ export function switchToChannel(channel) { return {error: true}; } browserHistory.push(`${teamUrl}/messages/@${channel.name}`); + } else if (channel.type === Constants.GM_CHANNEL) { + const gmChannel = getChannel(state, channel.id); + browserHistory.push(`${teamUrl}/channels/${gmChannel.name}`); } else { browserHistory.push(`${teamUrl}/channels/${channel.name}`); } @@ -66,6 +75,16 @@ export function switchToChannel(channel) { }; } +export function joinChannelById(channelId) { + return async (dispatch, getState) => { + const state = getState(); + const currentUserId = getCurrentUserId(state); + const currentTeamId = getCurrentTeamId(state); + + return dispatch(joinChannel(currentUserId, currentTeamId, channelId)); + }; +} + export function leaveChannel(channelId) { return async (dispatch, getState) => { const state = getState(); @@ -75,6 +94,9 @@ export function leaveChannel(channelId) { dispatch(unfavoriteChannel(channelId)); } + const teamUrl = getCurrentRelativeTeamUrl(state); + browserHistory.push(teamUrl + '/channels/' + Constants.DEFAULT_CHANNEL); + const {error} = await dispatch(leaveChannelRedux(channelId)); if (error) { return {error}; @@ -86,13 +108,12 @@ export function leaveChannel(channelId) { }; } -export function autocompleteUsersInChannel(prefix) { +export function autocompleteUsersInChannel(prefix, channelId) { return async (dispatch, getState) => { const state = getState(); const currentTeamId = getCurrentTeamId(state); - const currentChannelId = getCurrentChannelId(state); - return dispatch(autocompleteUsers(prefix, currentTeamId, currentChannelId)); + return dispatch(autocompleteUsers(prefix, currentTeamId, channelId)); }; } diff --git a/actions/views/channel.test.js b/actions/views/channel.test.js new file mode 100644 index 000000000000..bd0c531d44b5 --- /dev/null +++ b/actions/views/channel.test.js @@ -0,0 +1,107 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import {General} from 'mattermost-redux/constants'; +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'; + +const mockStore = configureStore([thunk]); + +jest.mock('utils/browser_history', () => ({ + browserHistory: { + push: jest.fn(), + }, +})); + +jest.mock('actions/channel_actions.jsx', () => ({ + openDirectChannelToUserId: jest.fn(() => { + return {type: ''}; + }), +})); + +jest.mock('mattermost-redux/actions/channels', () => ({ + leaveChannel: jest.fn(() => { + return {type: ''}; + }), +})); + +jest.mock('selectors/local_storage', () => ({ + getLastViewedChannelName: () => 'channel1', +})); + +describe('channel view actions', () => { + const channel1 = {id: 'channelid1', name: 'channel1', display_name: 'Channel 1', type: 'O', team_id: 'teamid1'}; + const townsquare = {id: 'channelid2', name: General.DEFAULT_CHANNEL, display_name: 'Town Square', type: 'O', team_id: 'teamid1'}; + const gmChannel = {id: 'gmchannelid', name: 'gmchannel', display_name: 'GM Channel 1', type: 'G'}; + const team1 = {id: 'teamid1', name: 'team1'}; + + const initialState = { + entities: { + users: { + currentUserId: 'userid1', + profiles: {userid1: {id: 'userid1', username: 'username1'}, userid2: {id: 'userid2', username: 'username2'}}, + profilesInChannel: {}, + }, + teams: { + currentTeamId: 'teamid1', + teams: {teamid1: team1}, + }, + channels: { + currentChannelId: 'channelid1', + channels: {channelid1: channel1, channelid2: townsquare, gmchannelid: gmChannel}, + myMembers: {gmchannelid: {channel_id: 'gmchannelid', user_id: 'userid1'}}, + }, + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, + }, + }; + + let store; + + beforeEach(() => { + store = mockStore(initialState); + }); + + describe('switchToChannel', () => { + test('switch to public channel', () => { + store.dispatch(Actions.switchToChannel(channel1)); + expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/channels/${channel1.name}`); + }); + + test('switch to fake direct channel', async () => { + await store.dispatch(Actions.switchToChannel({fake: true, userId: 'userid2', name: 'username2'})); + expect(openDirectChannelToUserId).toHaveBeenCalledWith('userid2'); + expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/messages/@username2`); + }); + + test('switch to gm channel', async () => { + await store.dispatch(Actions.switchToChannel(gmChannel)); + expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/channels/${gmChannel.name}`); + }); + }); + + describe('leaveChannel', () => { + test('leave a channel successfully', async () => { + await store.dispatch(Actions.leaveChannel('channelid')); + expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/channels/town-square`); + expect(leaveChannel).toHaveBeenCalledWith('channelid'); + }); + }); + + describe('goToLastViewedChannel', () => { + test('should switch to town square if last viewed channel is current channel', async () => { + await store.dispatch(Actions.goToLastViewedChannel()); + expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/channels/${General.DEFAULT_CHANNEL}`); + }); + }); +}); diff --git a/actions/views/create_comment.jsx b/actions/views/create_comment.jsx index afab3773263b..8264cb88709b 100644 --- a/actions/views/create_comment.jsx +++ b/actions/views/create_comment.jsx @@ -16,7 +16,6 @@ import {Posts} from 'mattermost-redux/constants'; import {isPostPendingOrFailed} from 'mattermost-redux/utils/post_utils'; import * as PostActions from 'actions/post_actions.jsx'; -import * as GlobalActions from 'actions/global_actions.jsx'; import {executeCommand} from 'actions/command'; import {setGlobalItem, actionOnGlobalItemsWithPrefix} from 'actions/storage'; import EmojiMap from 'utils/emoji_map'; @@ -78,8 +77,6 @@ export function submitPost(channelId, rootId, draft) { create_at: time, }; - GlobalActions.emitUserCommentedEvent(post); - dispatch(PostActions.createPost(post, draft.fileInfos)); }; } diff --git a/actions/views/create_comment.test.jsx b/actions/views/create_comment.test.jsx index 0d691824e5e1..c4cd16b17e59 100644 --- a/actions/views/create_comment.test.jsx +++ b/actions/views/create_comment.test.jsx @@ -22,7 +22,6 @@ import { } from 'actions/views/create_comment'; import {setGlobalItem, actionOnGlobalItemsWithPrefix} from 'actions/storage'; import * as PostActions from 'actions/post_actions.jsx'; -import * as GlobalActions from 'actions/global_actions.jsx'; import {executeCommand} from 'actions/command'; import {StoragePrefixes} from 'utils/constants'; @@ -107,15 +106,22 @@ describe('rhs view actions', () => { emojis: { customEmoji: {}, }, + general: { + config: { + EnableCustomEmoji: 'true', + }, + }, }, storage: { - [`${StoragePrefixes.COMMENT_DRAFT}${latestPostId}`]: { - value: { - message: '', - fileInfos: [], - uploadsInProgress: [], + storage: { + [`${StoragePrefixes.COMMENT_DRAFT}${latestPostId}`]: { + value: { + message: '', + fileInfos: [], + uploadsInProgress: [], + }, + timestamp: new Date(), }, - timestamp: new Date(), }, }, }; @@ -207,16 +213,6 @@ describe('rhs view actions', () => { user_id: currentUserId, }; - test('it calls GlobalActions.emitUserCommentedEvent with post', () => { - store.dispatch(submitPost(channelId, rootId, draft)); - - expect(GlobalActions.emitUserCommentedEvent).toHaveBeenCalled(); - - expect(lastCall(GlobalActions.emitUserCommentedEvent.mock.calls)[0]).toEqual( - expect.objectContaining(post) - ); - }); - test('it call PostActions.createPost with post', () => { store.dispatch(submitPost(channelId, rootId, draft)); @@ -351,13 +347,15 @@ describe('rhs view actions', () => { store = mockStore({ ...initialState, storage: { - [`${StoragePrefixes.COMMENT_DRAFT}${latestPostId}`]: { - value: { - message: 'test msg', - fileInfos: [], - uploadsInProgress: [], + storage: { + [`${StoragePrefixes.COMMENT_DRAFT}${latestPostId}`]: { + value: { + message: 'test msg', + fileInfos: [], + uploadsInProgress: [], + }, + timestamp: new Date(), }, - timestamp: new Date(), }, }, }); diff --git a/actions/views/posts.js b/actions/views/posts.js index f1a00a61c4e5..300032d64af7 100644 --- a/actions/views/posts.js +++ b/actions/views/posts.js @@ -3,21 +3,17 @@ import * as PostActions from 'mattermost-redux/actions/posts'; -import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; -import {ActionTypes} from 'utils/constants.jsx'; +import {logError} from 'mattermost-redux/actions/errors'; + +import {ActionTypes, AnnouncementBarTypes} from 'utils/constants.jsx'; export function editPost(post) { return async (dispatch, getState) => { const result = await PostActions.editPost(post)(dispatch, getState); - if (result.error) { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_ERROR, - err: { - id: result.error.server_error_id, - ...result.error, - }, - }); + // Send to error bar if it's an edit post error about time limit. + if (result.error && result.error.server_error_id === 'api.post.update_post.permissions_time_limit.app_error') { + dispatch(logError({type: AnnouncementBarTypes.ANNOUNCEMENT, message: result.error.message}, true)); } return result; diff --git a/actions/views/profile_popover.js b/actions/views/profile_popover.js new file mode 100644 index 000000000000..b42bdf319e06 --- /dev/null +++ b/actions/views/profile_popover.js @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getTeamMember} from 'mattermost-redux/actions/teams'; +import {getChannelMember} from 'mattermost-redux/actions/channels'; +import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; + +import {getSelectedPost} from 'selectors/rhs'; + +export function getMembershipForCurrentEntities(userId) { + return async (dispatch, getState) => { + const state = getState(); + const currentTeamId = getCurrentTeamId(state); + + const selectedPost = getSelectedPost(state); + const currentChannel = getCurrentChannel(state); + + let channelId; + if (selectedPost.exists === false) { + channelId = currentChannel.id; + } else { + channelId = selectedPost.channel_id; + } + + return Promise.all([dispatch(getTeamMember(currentTeamId, userId)), dispatch(getChannelMember(channelId, userId))]); + }; +} diff --git a/actions/views/root.js b/actions/views/root.js index 12a44591870b..8cc74b88609c 100644 --- a/actions/views/root.js +++ b/actions/views/root.js @@ -8,18 +8,20 @@ import {Client4} from 'mattermost-redux/client'; import {ActionTypes} from 'utils/constants'; export function loadMeAndConfig() { - return (dispatch) => { + return async (dispatch) => { // if any new promise needs to be added please be mindful of the order as it is used in root.jsx for redirection const promises = [ dispatch(getClientConfig()), dispatch(getLicenseConfig()), ]; + // need to await for clientConfig first as it is required for loadMe + const resolvedPromises = await Promise.all(promises); if (document.cookie.indexOf('MMUSERID=') > -1) { - promises.push(dispatch(UserActions.loadMe())); + resolvedPromises.push(await dispatch(UserActions.loadMe())); } - return Promise.all(promises); + return resolvedPromises; }; } @@ -36,3 +38,10 @@ export function loadTranslations(locale, url) { }).catch(() => {}); // eslint-disable-line no-empty-function }; } + +export function clearUserCookie() { + // We need to clear the cookie both with and without the domain set because we can't tell if the server set + // the cookie with it. At this time, the domain will be set if ServiceSettings.EnableCookiesForSubdomains is true. + document.cookie = 'MMUSERID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/'; + document.cookie = `MMUSERID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;domain=${window.location.hostname};path=/`; +} diff --git a/actions/views/root.test.js b/actions/views/root.test.js new file mode 100644 index 000000000000..c3679838a012 --- /dev/null +++ b/actions/views/root.test.js @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import thunk from 'redux-thunk'; +import configureStore from 'redux-mock-store'; + +import * as Actions from 'actions/views/root'; + +const mockStore = configureStore([thunk]); + +jest.mock('mattermost-redux/actions/general', () => { + const original = require.requireActual('mattermost-redux/actions/general'); + return { + ...original, + getClientConfig: () => ({type: 'MOCK_GET_CLIENT_CONFIG'}), + getLicenseConfig: () => ({type: 'MOCK_GET_LICENSE_CONFIG'}), + }; +}); + +jest.mock('mattermost-redux/actions/users', () => { + const original = require.requireActual('mattermost-redux/actions/users'); + return { + ...original, + loadMe: () => ({type: 'MOCK_LOAD_ME'}), + }; +}); + +describe('root view actions', () => { + const origCookies = document.cookie; + const origWasLoggedIn = localStorage.getItem('was_logged_in'); + + beforeAll(() => { + document.cookie = ''; + localStorage.setItem('was_logged_in', ''); + }); + + afterAll(() => { + document.cookie = origCookies; + localStorage.setItem('was_logged_in', origWasLoggedIn); + }); + + test('loadMeAndConfig, without user logged in', async () => { + const testStore = await mockStore({}); + + await testStore.dispatch(Actions.loadMeAndConfig()); + expect(testStore.getActions()).toEqual([{type: 'MOCK_GET_CLIENT_CONFIG'}, {type: 'MOCK_GET_LICENSE_CONFIG'}]); + }); + + test('loadMeAndConfig, with user logged in', async () => { + const testStore = await mockStore({}); + + document.cookie = 'MMUSERID=userid'; + localStorage.setItem('was_logged_in', 'true'); + + await testStore.dispatch(Actions.loadMeAndConfig()); + expect(testStore.getActions()).toEqual([{type: 'MOCK_GET_CLIENT_CONFIG'}, {type: 'MOCK_GET_LICENSE_CONFIG'}, {type: 'MOCK_LOAD_ME'}]); + }); +}); diff --git a/actions/websocket_actions.jsx b/actions/websocket_actions.jsx index aec54c6ed7a2..6f55373d2508 100644 --- a/actions/websocket_actions.jsx +++ b/actions/websocket_actions.jsx @@ -12,6 +12,7 @@ import { GeneralTypes, AdminTypes, IntegrationTypes, + PreferenceTypes, } from 'mattermost-redux/action_types'; import {WebsocketEvents, General} from 'mattermost-redux/constants'; import { @@ -40,7 +41,7 @@ import {loadChannelsForCurrentUser} from 'actions/channel_actions.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import {handleNewPost} from 'actions/post_actions.jsx'; import * as StatusActions from 'actions/status_actions.jsx'; -import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; +import {loadProfilesForSidebar} from 'actions/user_actions.jsx'; import store from 'stores/redux_store.jsx'; import WebSocketClient from 'client/web_websocket_client.jsx'; import {loadPlugin, loadPluginsIfNecessary, removePlugin} from 'plugins'; @@ -480,23 +481,11 @@ function handlePostEditEvent(msg) { dispatch(viewChannel(currentChannelId)); } } - - // Needed for search store - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.POST_UPDATED, - post, - }); } function handlePostDeleteEvent(msg) { const post = JSON.parse(msg.data.post); dispatch({type: PostTypes.POST_DELETED, data: post}); - - // Needed for search store - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.POST_DELETED, - post, - }); } async function handleTeamAddedEvent(msg) { @@ -532,7 +521,7 @@ function handleLeaveTeamEvent(msg) { } function handleUpdateTeamEvent(msg) { - dispatch({type: TeamTypes.UPDATED_TEAM, data: msg.data.team}); + dispatch({type: TeamTypes.UPDATED_TEAM, data: JSON.parse(msg.data.team)}); } function handleDeleteTeamEvent(msg) { @@ -726,17 +715,29 @@ function handleChannelDeletedEvent(msg) { function handlePreferenceChangedEvent(msg) { const preference = JSON.parse(msg.data.preference); - GlobalActions.emitPreferenceChangedEvent(preference); + dispatch({type: PreferenceTypes.RECEIVED_PREFERENCES, data: [preference]}); + + if (addedNewDmUser(preference)) { + loadProfilesForSidebar(); + } } function handlePreferencesChangedEvent(msg) { const preferences = JSON.parse(msg.data.preferences); - GlobalActions.emitPreferencesChangedEvent(preferences); + dispatch({type: PreferenceTypes.RECEIVED_PREFERENCES, data: preferences}); + + if (preferences.findIndex(addedNewDmUser) !== -1) { + loadProfilesForSidebar(); + } } function handlePreferencesDeletedEvent(msg) { const preferences = JSON.parse(msg.data.preferences); - GlobalActions.emitPreferencesDeletedEvent(preferences); + dispatch({type: PreferenceTypes.DELETED_PREFERENCES, data: preferences}); +} + +function addedNewDmUser(preference) { + return preference.category === Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW && preference.value === 'true'; } function handleUserTypingEvent(msg) { @@ -761,7 +762,7 @@ function handleUserTypingEvent(msg) { dispatch({ type: WebsocketEvents.STOP_TYPING, data, - }, getState); + }); }, parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds, 10)); if (!currentUser && userId !== currentUserId) { @@ -874,4 +875,4 @@ function handleOpenDialogEvent(msg) { } store.dispatch(openModal({modalId: ModalIdentifiers.INTERACTIVE_DIALOG, dialogType: InteractiveDialog})); -} \ No newline at end of file +} diff --git a/components/__snapshots__/loading_image_preview.test.jsx.snap b/components/__snapshots__/loading_image_preview.test.jsx.snap index 864775ccdd3c..93493998e5de 100644 --- a/components/__snapshots__/loading_image_preview.test.jsx.snap +++ b/components/__snapshots__/loading_image_preview.test.jsx.snap @@ -4,9 +4,8 @@ exports[`components/LoadingImagePreview should match snapshot 1`] = `
{'This software is offered under a commercial license.\n\nSee ENTERPRISE-EDITION-LICENSE.txt in your root install directory for details. See NOTICE.txt for information about open source software used in this system.\n\nYour subscription details are as follows:'}
- {`Name: ${this.props.license.Name}`}{uploadHelpText}
@@ -723,6 +817,7 @@ export default class PluginManagement extends AdminSettings {