diff --git a/.eslintrc.json b/.eslintrc.json index ced7e7206..7f0e42bf7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -27,7 +27,8 @@ "expect": true, "before": true, "beforeEach": true, - "after": true + "after": true, + "afterEach": true }, "rules": { "array-bracket-spacing": [2, "never"], diff --git a/app/actions/views/root.js b/app/actions/views/root.js index 7e308488b..c80376465 100644 --- a/app/actions/views/root.js +++ b/app/actions/views/root.js @@ -54,14 +54,15 @@ export function setStoreFromLocalData(data) { return; } - // TODO: uncomment and sorround with try/catch when PLT-4167 is merged - // const teamMembers = Client.getMyTeamMembers(); - const teamMembers = Object.keys(teams).map((key) => { - return { - team_id: key, - user_id: user.id - }; - }); + let teamMembers; + dispatch({type: TeamsTypes.MY_TEAM_MEMBERS_REQUEST}, getState); + try { + teamMembers = await Client.getMyTeamMembers(); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch({type: TeamsTypes.MY_TEAM_MEMBERS_FAILURE, error}, getState); + return; + } dispatch(batchActions([ { diff --git a/app/scenes/select_team/select_team.js b/app/scenes/select_team/select_team.js index 442d9f5f6..d44a2d567 100644 --- a/app/scenes/select_team/select_team.js +++ b/app/scenes/select_team/select_team.js @@ -41,25 +41,23 @@ export default class SelectTeam extends Component { for (const id of Object.keys(this.props.teams)) { const team = this.props.teams[id]; - // TODO: uncomment when PLT-4167 is merged - // if (this.props.myMembers.hasOwnProperty(id)) { - teams.push( - - ); - - // } + if (this.props.myMembers.hasOwnProperty(id)) { + teams.push( + + ); + } } return ( diff --git a/package.json b/package.json index 78775797e..c591b4374 100644 --- a/package.json +++ b/package.json @@ -34,14 +34,15 @@ "react-test-renderer": "15.3.2", "redux-logger": "2.7.0", "remote-redux-devtools": "0.5.0", - "remote-redux-devtools-on-debugger": "0.6.2" + "remote-redux-devtools-on-debugger": "0.6.2", + "ws": "1.1.1" }, "scripts": { "check": "node_modules/.bin/eslint --ext \".js\" --ignore-pattern node_modules --quiet .", "run-ios": "node node_modules/react-native/local-cli/cli.js run-ios", "run-android": "node node_modules/react-native/local-cli/cli.js run-android", "start": "node node_modules/react-native/local-cli/cli.js start", - "test": "mocha --compilers js:babel-register --require babel-polyfill", + "test": "NODE_ENV=test mocha --compilers js:babel-register --require babel-polyfill", "postinstall": "make post-install" } } diff --git a/service/actions/channels.js b/service/actions/channels.js index 77777e35b..d85c3eaf1 100644 --- a/service/actions/channels.js +++ b/service/actions/channels.js @@ -12,6 +12,15 @@ import {forceLogoutIfNecessary} from './helpers'; import {batchActions} from 'redux-batched-actions'; import Client from 'service/client'; +export function selectChannel(channelId) { + return async (dispatch, getState) => { + dispatch({ + type: ChannelTypes.SELECTED_CHANNEL, + data: channelId + }, getState); + }; +} + export function createChannel(channel, userId) { return async (dispatch, getState) => { dispatch(batchActions([ @@ -171,8 +180,10 @@ export function updateChannelNotifyProps(userId, teamId, channelId, props) { dispatch(batchActions([ { type: ChannelTypes.RECEIVED_CHANNEL_PROPS, - data: notifyProps, - channel_id: channelId + data: { + channel_id: channelId, + notifyProps + } }, { type: ChannelTypes.NOTIFY_PROPS_SUCCESS @@ -247,14 +258,14 @@ export function fetchMyChannelsAndMembers(teamId) { dispatch(batchActions([ { type: ChannelTypes.RECEIVED_CHANNELS, - data: await channels + data: channels }, { type: ChannelTypes.CHANNELS_SUCCESS }, { type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, - data: await channelMembers + data: channelMembers }, { type: ChannelTypes.CHANNEL_MEMBERS_SUCCESS @@ -278,7 +289,7 @@ export function leaveChannel(teamId, channelId) { dispatch(batchActions([ { type: ChannelTypes.LEAVE_CHANNEL, - channel_id: channelId + data: channelId }, { type: ChannelTypes.LEAVE_CHANNEL_SUCCESS @@ -346,7 +357,7 @@ export function deleteChannel(teamId, channelId) { dispatch(batchActions([ { type: ChannelTypes.RECEIVED_CHANNEL_DELETED, - channel_id: channelId + data: channelId }, { type: ChannelTypes.DELETE_CHANNEL_SUCCESS @@ -371,8 +382,10 @@ export function updateLastViewedAt(teamId, channelId, active) { dispatch(batchActions([ { type: ChannelTypes.RECEIVED_LAST_VIEWED, - channel_id: channelId, - last_viewed_at: new Date().getTime() + data: { + channel_id: channelId, + last_viewed_at: new Date().getTime() + } }, { type: ChannelTypes.UPDATE_LAST_VIEWED_SUCCESS @@ -461,7 +474,7 @@ export function removeChannelMember(teamId, channelId, userId) { dispatch({type: ChannelTypes.REMOVE_CHANNEL_MEMBER_REQUEST}, getState); try { - await Client.addChannelMember(teamId, channelId, userId); + await Client.removeChannelMember(teamId, channelId, userId); } catch (error) { forceLogoutIfNecessary(error, dispatch); dispatch({type: ChannelTypes.REMOVE_CHANNEL_MEMBER_FAILURE, error}, getState); @@ -482,6 +495,7 @@ export function removeChannelMember(teamId, channelId, userId) { } export default { + selectChannel, createChannel, createDirectChannel, updateChannel, diff --git a/service/actions/posts.js b/service/actions/posts.js index 9cd6d76cb..00993afc3 100644 --- a/service/actions/posts.js +++ b/service/actions/posts.js @@ -5,6 +5,35 @@ import Client from 'service/client'; import {batchActions} from 'redux-batched-actions'; import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; import {Constants, PostsTypes} from 'service/constants'; +import {getProfilesByIds, getStatusesByIds} from './users'; + +async function getProfilesAndStatusesForPosts(list, dispatch, getState) { + const {profiles, statuses} = getState().entities.users; + const posts = list.posts; + const profilesToLoad = []; + const statusesToLoad = []; + + Object.keys(posts).forEach((key) => { + const post = posts[key]; + const userId = post.user_id; + + if (!profiles[userId]) { + profilesToLoad.push(userId); + } + + if (!statuses[userId]) { + statusesToLoad.push(userId); + } + }); + + if (profilesToLoad.length) { + await getProfilesByIds(profilesToLoad)(dispatch, getState); + } + + if (statusesToLoad.length) { + await getStatusesByIds(statusesToLoad)(dispatch, getState); + } +} export function createPost(teamId, post) { return bindClientFunc( @@ -68,6 +97,7 @@ export function getPost(teamId, channelId, postId) { let post; try { post = await Client.getPost(teamId, channelId, postId); + getProfilesAndStatusesForPosts(post, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); dispatch({type: PostsTypes.GET_POST_FAILURE, error}, getState); @@ -94,6 +124,7 @@ export function getPosts(teamId, channelId, offset = 0, limit = Constants.POST_C try { posts = await Client.getPosts(teamId, channelId, offset, limit); + getProfilesAndStatusesForPosts(posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); dispatch({type: PostsTypes.GET_POSTS_FAILURE, error}, getState); @@ -120,6 +151,7 @@ export function getPostsSince(teamId, channelId, since) { let posts; try { posts = await Client.getPostsSince(teamId, channelId, since); + getProfilesAndStatusesForPosts(posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); dispatch({type: PostsTypes.GET_POSTS_SINCE_FAILURE, error}, getState); @@ -146,6 +178,7 @@ export function getPostsBefore(teamId, channelId, postId, offset = 0, limit = Co let posts; try { posts = await Client.getPostsBefore(teamId, channelId, postId, offset, limit); + getProfilesAndStatusesForPosts(posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); dispatch({type: PostsTypes.GET_POSTS_BEFORE_FAILURE, error}, getState); @@ -172,6 +205,7 @@ export function getPostsAfter(teamId, channelId, postId, offset = 0, limit = Con let posts; try { posts = await Client.getPostsAfter(teamId, channelId, postId, offset, limit); + getProfilesAndStatusesForPosts(posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); dispatch({type: PostsTypes.GET_POSTS_AFTER_FAILURE, error}, getState); diff --git a/service/actions/users.js b/service/actions/users.js index 1a12c0b46..ac232d22d 100644 --- a/service/actions/users.js +++ b/service/actions/users.js @@ -3,10 +3,7 @@ import {batchActions} from 'redux-batched-actions'; import Client from 'service/client'; - -// TODO: uncomment when PLT-4167 is merged -// import {Constants, PreferencesTypes, UsersTypes, TeamsTypes} from 'constants'; -import {Constants, PreferencesTypes, UsersTypes} from 'service/constants'; +import {Constants, PreferencesTypes, UsersTypes, TeamsTypes} from 'service/constants'; import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; export function login(loginId, password, mfaToken = '') { @@ -15,16 +12,13 @@ export function login(loginId, password, mfaToken = '') { Client.login(loginId, password, mfaToken). then(async (data) => { - // TODO: uncomment when PLT-4167 is merged - // let teamMembers; + let teamMembers; let preferences; try { - // TODO: uncomment when PLT-4167 is merged - // const teamMembersRequest = Client.getMyTeamMembers(); + const teamMembersRequest = Client.getMyTeamMembers(); const preferencesRequest = Client.getMyPreferences(); - // TODO: uncomment when PLT-4167 is merged - // teamMembers = await teamMembersRequest; + teamMembers = await teamMembersRequest; preferences = await preferencesRequest; } catch (err) { dispatch({type: UsersTypes.LOGIN_FAILURE, error: err}, getState); @@ -40,12 +34,10 @@ export function login(loginId, password, mfaToken = '') { type: PreferencesTypes.RECEIVED_PREFERENCES, data: await preferences }, - - // TODO: uncomment when PLT-4167 is merged - // { - // type: TeamsTypes.RECEIVED_MY_TEAM_MEMBERS, - // data: await teamMembers - // }, + { + type: TeamsTypes.RECEIVED_MY_TEAM_MEMBERS, + data: await teamMembers + }, { type: UsersTypes.LOGIN_SUCCESS } diff --git a/service/actions/websocket.js b/service/actions/websocket.js new file mode 100644 index 000000000..7efa1bc69 --- /dev/null +++ b/service/actions/websocket.js @@ -0,0 +1,377 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Client from 'service/client'; +import websocketClient from 'service/client/websocket_client'; +import {batchActions} from 'redux-batched-actions'; +import { + Constants, + ChannelTypes, + GeneralTypes, + PostsTypes, + PreferencesTypes, + TeamsTypes, + UsersTypes, + WebsocketEvents +} from 'service/constants'; + +import { + fetchMyChannelsAndMembers, + getChannel, + getChannelStats, + updateLastViewedAt +} from 'service/actions/channels'; + +import { + getPosts, + getPostsSince +} from 'service/actions/posts'; + +import {getProfilesByIds, getStatusesByIds} from 'service/actions/users'; + +export function init(siteUrl, token) { + return async (dispatch, getState) => { + dispatch({type: GeneralTypes.WEBSOCKET_REQUEST}, getState); + const config = getState().entities.general.config; + let connUrl = siteUrl || Client.getUrl(); + const authToken = token || Client.getToken(); + + // replace the protocol with a websocket one + if (connUrl.startsWith('https:')) { + connUrl = connUrl.replace(/^https:/, 'wss:'); + } else { + connUrl = connUrl.replace(/^http:/, 'ws:'); + } + + // append a port number if one isn't already specified + if (!(/:\d+$/).test(connUrl)) { + if (connUrl.startsWith('wss:')) { + connUrl += ':' + (config.WebsocketSecurePort || 443); + } else { + connUrl += ':' + (config.WebsocketPort || 80); + } + } + + connUrl += `${Client.getUrlVersion()}/users/websocket`; + websocketClient.setFirstConnectCallback(handleFirstConnect); + websocketClient.setEventCallback(handleEvent); + websocketClient.setReconnectCallback(handleReconnect); + websocketClient.setCloseCallback(handleClose); + return websocketClient.initialize(connUrl, authToken, dispatch, getState); + }; +} + +export function close() { + return async (dispatch, getState) => { + websocketClient.close(); + if (dispatch) { + dispatch({type: GeneralTypes.WEBSOCKET_FAILURE, error: 'Closed'}, getState); + } + }; +} + +function handleFirstConnect(dispatch, getState) { + dispatch({type: GeneralTypes.WEBSOCKET_SUCCESS}, getState); +} + +function handleReconnect(dispatch, getState) { + const entities = getState().entities; + const currentTeamId = entities.teams.currentId; + const currentChannelId = entities.channels.currentId; + + if (currentTeamId) { + fetchMyChannelsAndMembers(currentTeamId)(dispatch, getState); + + if (currentChannelId) { + loadPostsHelper(currentTeamId, currentChannelId, dispatch, getState); + } + } + + dispatch({type: GeneralTypes.WEBSOCKET_SUCCESS}, getState); +} + +function handleClose(connectFailCount, dispatch, getState) { + dispatch({type: GeneralTypes.WEBSOCKET_FAILURE, error: connectFailCount}, getState); +} + +function handleEvent(msg, dispatch, getState) { + switch (msg.event) { + case WebsocketEvents.POSTED: + case WebsocketEvents.EPHEMERAL_MESSAGE: + handleNewPostEvent(msg, dispatch, getState); + break; + case WebsocketEvents.POST_EDITED: + handlePostEdited(msg, dispatch, getState); + break; + case WebsocketEvents.POST_DELETED: + handlePostDeleted(msg, dispatch, getState); + break; + case WebsocketEvents.LEAVE_TEAM: + handleLeaveTeamEvent(msg, dispatch, getState); + break; + case WebsocketEvents.USER_ADDED: + handleUserAddedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.USER_REMOVED: + handleUserRemovedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.USER_UPDATED: + handleUserUpdatedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.CHANNEL_VIEWED: + handleChannelViewedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.CHANNEL_DELETED: + handleChannelDeletedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.DIRECT_ADDED: + handleDirectAddedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.PREFERENCE_CHANGED: + handlePreferenceChangedEvent(msg, dispatch, getState); + break; + case WebsocketEvents.STATUS_CHANGED: + handleStatusChangedEvent(msg, dispatch, getState); + break; + } +} + +function handleNewPostEvent(msg, dispatch, getState) { + const state = getState(); + const users = state.entities.users; + const channels = state.entities.channels; + const teams = state.entities.teams; + const {posts} = state.entities.posts; + const isActive = state.entities.general.appState; + const post = JSON.parse(msg.data.post); + const userId = post.user_id; + const teamId = msg.data.team_id; + const status = users.statuses[userId]; + + if (!users.profiles[userId]) { + getProfilesByIds([userId])(dispatch, getState); + } + + if (status !== Constants.ONLINE) { + getStatusesByIds([userId])(dispatch, getState); + } + + if (post.channel_id === channels.currentId) { + if (isActive) { + updateLastViewedAt(teamId, post.channel_id)(dispatch, getState); + } else { + getChannel(teamId, post.channel_id)(dispatch, getState); + } + } else if (teamId === teams.currentId || msg.data.channel_type === Constants.DM_CHANNEL) { + getChannel(teamId, post.channel_id)(dispatch, getState); + } + + if (post.root_id && !posts[post.root_id]) { + Client.getPost(teamId, post.channel_id, post.root_id).then((data) => { + const rootUserId = data.posts[post.root_id].user_id; + const rootStatus = users.statuses[rootUserId]; + if (!users.profiles[rootUserId]) { + getProfilesByIds([rootUserId])(dispatch, getState); + } + + if (rootStatus !== Constants.ONLINE) { + getStatusesByIds([rootUserId])(dispatch, getState); + } + + dispatch({ + type: PostsTypes.RECEIVED_POSTS, + data, + channelId: post.channel_id + }, getState); + }); + } + + dispatch({ + type: PostsTypes.RECEIVED_POSTS, + data: { + order: [], + posts: { + [post.id]: post + } + }, + channelId: post.channel_id + }, getState); +} + +function handlePostEdited(msg, dispatch, getState) { + const state = getState(); + const channels = state.entities.channels; + const isActive = state.entities.general.appState; + const data = JSON.parse(msg.data.post); + + dispatch({type: PostsTypes.RECEIVED_POST, data}, getState); + + if (msg.broadcast.channel_id === channels.currentId && isActive) { + // FIXME: Update post should include team_id in the message cause its not always the current team + // updateLastViewedAt(msg.data.team_id, data.channel_id)(dispatch, getState); + } +} + +function handlePostDeleted(msg, dispatch, getState) { + const data = JSON.parse(msg.data.post); + dispatch({type: PostsTypes.POST_DELETED, data}, getState); +} + +function handleLeaveTeamEvent(msg, dispatch, getState) { + const entities = getState().entities; + const teams = entities.teams; + const users = entities.users; + + if (users.currentId === msg.data.user_id) { + dispatch({type: TeamsTypes.LEAVE_TEAM, data: teams.teams[msg.data.team_id]}, getState); + + // if they are on the team being removed deselect the current team and channel + if (teams.currentId === msg.data.team_id) { + dispatch(batchActions([ + { + type: TeamsTypes.SELECT_TEAM, + data: '' + }, + { + type: ChannelTypes.SELECTED_CHANNEL, + data: '' + } + ]), getState); + } + } +} + +function handleUserAddedEvent(msg, dispatch, getState) { + const state = getState(); + const channels = state.entities.channels; + const teams = state.entities.teams; + const users = state.entities.users; + const teamId = msg.data.team_id; + + if (msg.broadcast.channel_id === channels.currentId) { + getChannelStats(teamId, channels.currentId)(dispatch, getState); + } + + if (teamId === teams.currentId && msg.data.user_id === users.currentId) { + getChannel(teamId, msg.broadcast.channel_id)(dispatch, getState); + } +} + +function handleUserRemovedEvent(msg, dispatch, getState) { + const state = getState(); + const channels = state.entities.channels; + const teams = state.entities.teams; + const users = state.entities.users; + const teamId = teams.currentId; + + if (msg.broadcast.user_id === users.currentId && teamId) { + fetchMyChannelsAndMembers(teamId)(dispatch, getState); + } else if (msg.broadcast.channel_id === channels.currentId) { + getChannelStats(teamId, channels.currentId)(dispatch, getState); + } +} + +function handleUserUpdatedEvent(msg, dispatch, getState) { + const entities = getState().entities; + const users = entities.users; + const user = msg.data.user; + + if (user.id !== users.currentId) { + dispatch({ + type: UsersTypes.RECEIVED_PROFILES, + data: { + [user.id]: user + } + }, getState); + } +} + +function handleChannelViewedEvent(msg, dispatch, getState) { + const state = getState(); + const channels = state.entities.channels; + const teams = state.entities.teams; + const users = state.entities.users; + + if (teams.currentId === msg.broadcast.team_id && channels.currentId !== msg.data.channel_id && + users.currentId === msg.broadcast.user_id) { + getChannel(teams.currentId, msg.data.channel_id)(dispatch, getState); + } +} + +function handleChannelDeletedEvent(msg, dispatch, getState) { + const entities = getState().entities; + const {channels, currentId} = entities.channels; + const teams = entities.teams; + + if (msg.broadcast.team_id === teams.currentId) { + dispatch({type: ChannelTypes.RECEIVED_CHANNEL_DELETED, data: msg.data.channel_id}, getState); + + if (msg.data.channel_id === currentId) { + let channelId = ''; + const channel = Object.keys(channels).filter((key) => channels[key].name === Constants.DEFAULT_CHANNEL); + + if (channel.length) { + channelId = channel[0]; + } + + dispatch({type: ChannelTypes.SELECTED_CHANNEL, data: channelId}, getState); + } + + fetchMyChannelsAndMembers(teams.currentId)(dispatch, getState); + } +} + +function handleDirectAddedEvent(msg, dispatch, getState) { + const state = getState(); + const teams = state.entities.teams; + + getChannel(teams.currentId, msg.broadcast.channel_id)(dispatch, getState); +} + +function handlePreferenceChangedEvent(msg, dispatch, getState) { + const preference = JSON.parse(msg.data.preference); + dispatch({type: PreferencesTypes.RECEIVED_PREFERENCES, data: [preference]}, getState); + + if (preference.category === Constants.CATEGORY_DIRECT_CHANNEL_SHOW) { + const state = getState(); + const users = state.entities.users; + const userId = preference.name; + const status = users.statuses[userId]; + + if (!users.profiles[userId]) { + getProfilesByIds([userId])(dispatch, getState); + } + + if (status !== Constants.ONLINE) { + getStatusesByIds([userId])(dispatch, getState); + } + } +} + +function handleStatusChangedEvent(msg, dispatch, getState) { + dispatch({ + type: UsersTypes.RECEIVED_STATUSES, + data: { + [msg.data.user_id]: msg.data.status + } + }, getState); +} + +// Helpers + +function loadPostsHelper(teamId, channelId, dispatch, getState) { + const {posts, postsByChannel} = getState().entities.posts; + const postsArray = postsByChannel[channelId]; + const latestPostId = postsArray[postsArray.length - 1]; + + let latestPostTime = 0; + if (latestPostId) { + latestPostTime = posts[latestPostId].create_at || 0; + } + + if (Object.keys(posts).length === 0 || postsArray.length < Constants.POST_CHUNK_SIZE || latestPostTime === 0) { + getPosts(teamId, channelId)(dispatch, getState); + } else { + getPostsSince(teamId, channelId, latestPostTime)(dispatch, getState); + } +} diff --git a/service/client/client.js b/service/client/client.js index dac34aa67..3ac3b93ff 100644 --- a/service/client/client.js +++ b/service/client/client.js @@ -38,6 +38,10 @@ export default class Client { this.token = token; } + getUrlVersion() { + return this.urlVersion; + } + getBaseRoute() { return `${this.url}${this.urlVersion}`; } @@ -581,9 +585,7 @@ export default class Client { ); }; - // Client helpers // Preferences routes - getMyPreferences = async () => { return this.doFetch( `${this.getPreferencesRoute()}/`, @@ -619,6 +621,7 @@ export default class Client { ); }; + // Client helpers doFetch = async (url, options) => { const {data} = await this.doFetchWithResponse(url, options); diff --git a/service/client/websocket_client.js b/service/client/websocket_client.js new file mode 100644 index 000000000..aacd682f8 --- /dev/null +++ b/service/client/websocket_client.js @@ -0,0 +1,200 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const MAX_WEBSOCKET_FAILS = 7; +const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec +const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins + +/* eslint-disable global-require, no-process-env */ + +let Socket; +if (process.env.NODE_ENV === 'test') { + Socket = require('ws'); +} else { + Socket = WebSocket; +} + +/* eslint-enable global-require, no-process-env */ + +class WebSocketClient { + constructor() { + this.conn = null; + this.connectionUrl = null; + this.sequence = 1; + this.connectFailCount = 0; + this.eventCallback = null; + this.firstConnectCallback = null; + this.reconnectCallback = null; + this.errorCallback = null; + this.closeCallback = null; + this.dispatch = null; + this.getState = null; + } + + initialize(connectionUrl = this.connectionUrl, token, dispatch, getState) { + return new Promise((resolve, reject) => { + if (this.conn) { + resolve(); + return; + } + + if (connectionUrl == null) { + console.log('websocket must have connection url'); //eslint-disable-line no-console + reject('websocket must have connection url'); + return; + } + + if (!dispatch) { + console.log('websocket must have a dispatch'); //eslint-disable-line no-console + reject('websocket must have a dispatch'); + return; + } + + if (this.connectFailCount === 0) { + console.log('websocket connecting to ' + connectionUrl); //eslint-disable-line no-console + } + + this.conn = new Socket(connectionUrl); + this.connectionUrl = connectionUrl; + this.dispatch = dispatch; + this.getState = getState; + + this.conn.onopen = () => { + if (token) { + this.sendMessage('authentication_challenge', {token}); + } + + if (this.connectFailCount > 0) { + console.log('websocket re-established connection'); //eslint-disable-line no-console + if (this.reconnectCallback) { + this.reconnectCallback(this.dispatch, this.getState); + } + } else if (this.firstConnectCallback) { + this.firstConnectCallback(this.dispatch, this.getState); + resolve(); + } + + this.connectFailCount = 0; + }; + + this.conn.onclose = () => { + this.conn = null; + this.sequence = 1; + + if (this.connectFailCount === 0) { + console.log('websocket closed'); //eslint-disable-line no-console + } + + this.connectFailCount++; + + if (this.closeCallback) { + this.closeCallback(this.connectFailCount, this.dispatch, this.getState); + } + + let retryTime = MIN_WEBSOCKET_RETRY_TIME; + + // If we've failed a bunch of connections then start backing off + if (this.connectFailCount > MAX_WEBSOCKET_FAILS) { + retryTime = MIN_WEBSOCKET_RETRY_TIME * this.connectFailCount * this.connectFailCount; + if (retryTime > MAX_WEBSOCKET_RETRY_TIME) { + retryTime = MAX_WEBSOCKET_RETRY_TIME; + } + } + + setTimeout( + () => { + this.initialize(connectionUrl, token); + }, + retryTime + ); + }; + + this.conn.onerror = (evt) => { + if (this.connectFailCount <= 1) { + console.log('websocket error'); //eslint-disable-line no-console + console.log(evt); //eslint-disable-line no-console + } + + if (this.errorCallback) { + this.errorCallback(evt, this.dispatch, this.getState); + } + }; + + this.conn.onmessage = (evt) => { + const msg = JSON.parse(evt.data); + if (msg.seq_reply) { + if (msg.error) { + console.log(msg); //eslint-disable-line no-console + } + } else if (this.eventCallback) { + this.eventCallback(msg, this.dispatch, this.getState); + } + }; + }); + } + + setEventCallback(callback) { + this.eventCallback = callback; + } + + setFirstConnectCallback(callback) { + this.firstConnectCallback = callback; + } + + setReconnectCallback(callback) { + this.reconnectCallback = callback; + } + + setErrorCallback(callback) { + this.errorCallback = callback; + } + + setCloseCallback(callback) { + this.closeCallback = callback; + } + + close() { + this.connectFailCount = 0; + this.sequence = 1; + if (this.conn && this.conn.readyState === Socket.OPEN) { + this.conn.onclose = () => {}; //eslint-disable-line no-empty-function + this.conn.close(); + this.conn = null; + console.log('websocket closed'); //eslint-disable-line no-console + } + } + + sendMessage(action, data) { + const msg = { + action, + seq: this.sequence++, + data + }; + + if (this.conn && this.conn.readyState === Socket.OPEN) { + this.conn.send(JSON.stringify(msg)); + } else if (!this.conn || this.conn.readyState === Socket.CLOSED) { + this.conn = null; + this.initialize(); + } + } + + userTyping(channelId, parentId) { + this.sendMessage('user_typing', { + channel_id: channelId, + parent_id: parentId + }); + } + + getStatuses() { + this.sendMessage('get_statuses', null); + } + + getStatusesByIds(userIds) { + this.sendMessage('get_statuses_by_ids', { + user_ids: userIds + }); + } +} + +export default new WebSocketClient(); diff --git a/service/constants/constants.js b/service/constants/constants.js index 8c5ef28fd..14038f9fc 100644 --- a/service/constants/constants.js +++ b/service/constants/constants.js @@ -6,13 +6,22 @@ const Constants = { PROFILE_CHUNK_SIZE: 100, CHANNELS_CHUNK_SIZE: 50, + OFFLINE: 'offline', + AWAY: 'away', + ONLINE: 'online', + TEAM_USER_ROLE: 'team_user', TEAM_ADMIN_ROLE: 'team_admin', CHANNEL_USER_ROLE: 'channel_user', CHANNEL_ADMIN_ROLE: 'channel_admin', - POST_DELETED: 'DELETED' + DEFAULT_CHANNEL: 'town-square', + DM_CHANNEL: 'D', + + POST_DELETED: 'DELETED', + + CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show' }; export default Constants; diff --git a/service/constants/general.js b/service/constants/general.js index e60d1cec1..5200c997f 100644 --- a/service/constants/general.js +++ b/service/constants/general.js @@ -24,7 +24,11 @@ const GeneralTypes = keyMirror({ LOG_CLIENT_ERROR_REQUEST: null, LOG_CLIENT_ERROR_SUCCESS: null, - LOG_CLIENT_ERROR_FAILURE: null + LOG_CLIENT_ERROR_FAILURE: null, + + WEBSOCKET_REQUEST: null, + WEBSOCKET_SUCCESS: null, + WEBSOCKET_FAILURE: null }); export default GeneralTypes; diff --git a/service/constants/index.js b/service/constants/index.js index 5fe222e42..7385ca577 100644 --- a/service/constants/index.js +++ b/service/constants/index.js @@ -10,6 +10,7 @@ import PostsTypes from './posts'; import PreferencesTypes from './preferences'; import RequestStatus from './request_status'; import Themes from './themes'; +import WebsocketEvents from './websocket'; const Preferences = { CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show', @@ -26,5 +27,6 @@ export { PreferencesTypes, Preferences, RequestStatus, - Themes + Themes, + WebsocketEvents }; diff --git a/service/constants/websocket.js b/service/constants/websocket.js new file mode 100644 index 000000000..e0887e0ce --- /dev/null +++ b/service/constants/websocket.js @@ -0,0 +1,25 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const WebsocketEvents = { + POSTED: 'posted', + POST_EDITED: 'post_edited', + POST_DELETED: 'post_deleted', + CHANNEL_DELETED: 'channel_deleted', + CHANNEL_VIEWED: 'channel_viewed', + DIRECT_ADDED: 'direct_added', + LEAVE_TEAM: 'leave_team', + USER_ADDED: 'user_added', + USER_REMOVED: 'user_removed', + USER_UPDATED: 'user_updated', + TYPING: 'typing', + PREFERENCE_CHANGED: 'preference_changed', + EPHEMERAL_MESSAGE: 'ephemeral_message', + STATUS_CHANGED: 'status_change', + HELLO: 'hello', + WEBRTC: 'webrtc', + REACTION_ADDED: 'reaction_added', + REACTION_REMOVED: 'reaction_removed' +}; + +export default WebsocketEvents; diff --git a/service/reducers/entities/channels.js b/service/reducers/entities/channels.js index ece62fc2c..4188b2589 100644 --- a/service/reducers/entities/channels.js +++ b/service/reducers/entities/channels.js @@ -6,6 +6,8 @@ import {combineReducers} from 'redux'; function currentId(state = '', action) { switch (action.type) { + case ChannelTypes.SELECTED_CHANNEL: + return action.data; case UsersTypes.LOGOUT_SUCCESS: return ''; default: @@ -30,6 +32,9 @@ function channels(state = {}, action) { } return nextState; } + case ChannelTypes.RECEIVED_CHANNEL_DELETED: + Reflect.deleteProperty(nextState, action.data); + return nextState; case UsersTypes.LOGOUT_SUCCESS: return {}; @@ -56,26 +61,26 @@ function myMembers(state = {}, action) { return nextState; } case ChannelTypes.RECEIVED_CHANNEL_PROPS: { - const member = {...state[action.channel_id]}; - member.notify_props = action.data; + const member = {...state[action.data.channel_id]}; + member.notify_props = action.data.notifyProps; return { ...state, - [action.channel_id]: member + [action.data.channel_id]: member }; } case ChannelTypes.RECEIVED_LAST_VIEWED: { - const member = {...state[action.channel_id]}; - member.last_viewed_at = action.last_viewed_at; + const member = {...state[action.data.channel_id]}; + member.last_viewed_at = action.data.last_viewed_at; return { ...state, - [action.channel_id]: member + [action.data.channel_id]: member }; } case ChannelTypes.LEAVE_CHANNEL: case ChannelTypes.RECEIVED_CHANNEL_DELETED: - Reflect.deleteProperty(nextState, action.channel_id); + Reflect.deleteProperty(nextState, action.data); return nextState; case UsersTypes.LOGOUT_SUCCESS: diff --git a/service/reducers/entities/posts.js b/service/reducers/entities/posts.js index f0f565790..4beeafcc9 100644 --- a/service/reducers/entities/posts.js +++ b/service/reducers/entities/posts.js @@ -3,6 +3,13 @@ import {Constants, PostsTypes, UsersTypes} from 'service/constants'; +function initialState() { + return { + posts: {}, + postsByChannel: {} + }; +} + function handleReceivedPost(posts = {}, postsByChannel = {}, action) { const post = action.data; const channelId = post.channel_id; @@ -123,7 +130,7 @@ function handleRemovePost(posts = {}, postsByChannel = {}, action) { return {posts: nextPosts, postsByChannel: nextPostsByChannel}; } -function handlePosts(state = {}, action) { +function handlePosts(state = initialState(), action) { switch (action.type) { case PostsTypes.RECEIVED_POST: return handleReceivedPost(state.posts, state.postsByChannel, action); @@ -135,10 +142,7 @@ function handlePosts(state = {}, action) { return handleRemovePost(state.posts, state.postsByChannel, action); case UsersTypes.LOGOUT_SUCCESS: - return { - posts: {}, - postsByChannel: {} - }; + return initialState(); default: return state; } @@ -162,7 +166,7 @@ function currentFocusedPostId(state = '', action) { } } -export default function(state = {}, action) { +export default function(state = initialState(), action) { const {posts, postsByChannel} = handlePosts(state, action); const nextState = { diff --git a/service/reducers/entities/teams.js b/service/reducers/entities/teams.js index b3acd7179..41e2bd491 100644 --- a/service/reducers/entities/teams.js +++ b/service/reducers/entities/teams.js @@ -48,8 +48,8 @@ function myMembers(state = {}, action) { case TeamsTypes.LEAVE_TEAM: { const nextState = {...state}; - const data = action.team; - Reflect.deleteProperty(nextState, data.team_id); + const data = action.data; + Reflect.deleteProperty(nextState, data.id); return nextState; } case UsersTypes.LOGOUT_SUCCESS: diff --git a/service/reducers/requests/general.js b/service/reducers/requests/general.js index 9008af3b7..6d8867377 100644 --- a/service/reducers/requests/general.js +++ b/service/reducers/requests/general.js @@ -35,8 +35,19 @@ function license(state = initialRequestState(), action) { ); } +function websocket(state = initialRequestState(), action) { + return handleRequest( + GeneralTypes.WEBSOCKET_REQUEST, + GeneralTypes.WEBSOCKET_SUCCESS, + GeneralTypes.WEBSOCKET_FAILURE, + state, + action + ); +} + export default combineReducers({ server, config, - license + license, + websocket }); diff --git a/test/service/actions/channels.test.js b/test/service/actions/channels.test.js index 4fe1512d2..24c7a92d4 100644 --- a/test/service/actions/channels.test.js +++ b/test/service/actions/channels.test.js @@ -4,7 +4,6 @@ import assert from 'assert'; import * as Actions from 'service/actions/channels'; -import {getProfiles} from 'service/actions/users'; import Client from 'service/client'; import configureStore from 'app/store'; import {RequestStatus} from 'service/constants'; @@ -66,7 +65,6 @@ describe('Actions.Channels', () => { const state = store.getState(); const channels = state.entities.channels.channels; const members = state.entities.channels.myMembers; - const profiles = state.entities.users.profiles; const preferences = state.entities.preferences.myPreferences; const createRequest = state.requests.channels.createChannel; @@ -80,7 +78,6 @@ describe('Actions.Channels', () => { const membersCount = Object.keys(members).length; assert.ok(channels, 'channels is empty'); assert.ok(members, 'members is empty'); - assert.ok(profiles[user.id], 'profiles does not have userId'); assert.ok(Object.keys(preferences).length, 'preferences is empty'); assert.ok(channels[Object.keys(members)[0]], 'channels should have the member'); assert.ok(members[Object.keys(channels)[0]], 'members should belong to channel'); @@ -93,7 +90,6 @@ describe('Actions.Channels', () => { } }); - await getProfiles(0)(store.dispatch, store.getState); Actions.createDirectChannel(TestHelper.basicTeam.id, TestHelper.basicUser.id, user.id)(store.dispatch, store.getState); }); }).timeout(3000); @@ -176,7 +172,7 @@ describe('Actions.Channels', () => { Actions.fetchMyChannelsAndMembers(TestHelper.basicTeam.id)(store.dispatch, store.getState); }); - }); + }).timeout(3000); it('updateChannelNotifyProps', (done) => { TestHelper.initBasic(Client).then(async () => { diff --git a/test/service/actions/posts.test.js b/test/service/actions/posts.test.js index a345758a2..bcb8ef80e 100644 --- a/test/service/actions/posts.test.js +++ b/test/service/actions/posts.test.js @@ -156,19 +156,23 @@ describe('Actions.Posts', () => { channelId )(store.dispatch, store.getState); + let shouldStop = false; store.subscribe(() => { const state = store.getState(); const {posts, postsByChannel} = state.entities.posts; - assert.ok(posts); - assert.ok(postsByChannel); - assert.ok(postsByChannel[channelId]); + if (!shouldStop) { + assert.ok(posts); + assert.ok(postsByChannel); + assert.ok(postsByChannel[channelId]); - assert.equal(postsByChannel[channelId].length, 0); - assert.ok(!posts[postId]); - assert.ok(!posts[post1a.id]); + assert.equal(postsByChannel[channelId].length, 0); + assert.ok(!posts[postId]); + assert.ok(!posts[post1a.id]); - done(); + shouldStop = true; + done(); + } }); Actions.removePost( @@ -189,30 +193,35 @@ describe('Actions.Posts', () => { TestHelper.fakePost(channelId) ); + let shouldStop = false; store.subscribe(() => { const state = store.getState(); const getRequest = state.requests.posts.getPost; const {posts, postsByChannel} = state.entities.posts; - if (getRequest.status === RequestStatus.SUCCESS) { - assert.ok(posts); - assert.ok(postsByChannel); - assert.ok(postsByChannel[channelId]); + if (!shouldStop && + (getRequest.status === RequestStatus.SUCCESS || getRequest.status === RequestStatus.FAILURE)) { + if (getRequest.error) { + shouldStop = true; + done(new Error(JSON.stringify(getRequest.error))); + } else { + assert.ok(posts); + assert.ok(postsByChannel); + assert.ok(postsByChannel[channelId]); - assert.ok(posts[post.id]); + assert.ok(posts[post.id]); - let found = false; - for (const postIdInChannel of postsByChannel[channelId]) { - if (postIdInChannel === post.id) { - found = true; - break; + let found = false; + for (const postIdInChannel of postsByChannel[channelId]) { + if (postIdInChannel === post.id) { + found = true; + break; + } } + assert.ok(found, 'failed to find post in postsByChannel'); + shouldStop = true; + done(); } - assert.ok(found, 'failed to find post in postsByChannel'); - - done(); - } else if (getRequest.status === RequestStatus.FAILURE) { - done(new Error(JSON.stringify(getRequest.error))); } }); @@ -252,30 +261,36 @@ describe('Actions.Posts', () => { {...TestHelper.fakePost(channelId), root_id: post3.id} ); + let shouldStop = false; store.subscribe(() => { const state = store.getState(); const getRequest = state.requests.posts.getPosts; const {posts, postsByChannel} = state.entities.posts; - if (getRequest.status === RequestStatus.SUCCESS) { - assert.ok(posts); - assert.ok(postsByChannel); + if (!shouldStop && + (getRequest.status === RequestStatus.SUCCESS || getRequest.status === RequestStatus.FAILURE)) { + if (getRequest.error) { + shouldStop = true; + done(new Error(JSON.stringify(getRequest.error))); + } else { + assert.ok(posts); + assert.ok(postsByChannel); - const postsInChannel = postsByChannel[channelId]; - assert.ok(postsInChannel); - assert.equal(postsInChannel[0], post3a.id, 'wrong order for post3a'); - assert.equal(postsInChannel[1], post3.id, 'wrong order for post3'); - assert.equal(postsInChannel[3], post1a.id, 'wrong order for post1a'); + const postsInChannel = postsByChannel[channelId]; + assert.ok(postsInChannel); + assert.equal(postsInChannel[0], post3a.id, 'wrong order for post3a'); + assert.equal(postsInChannel[1], post3.id, 'wrong order for post3'); + assert.equal(postsInChannel[3], post1a.id, 'wrong order for post1a'); - assert.ok(posts[post1.id]); - assert.ok(posts[post1a.id]); - assert.ok(posts[post2.id]); - assert.ok(posts[post3.id]); - assert.ok(posts[post3a.id]); + assert.ok(posts[post1.id]); + assert.ok(posts[post1a.id]); + assert.ok(posts[post2.id]); + assert.ok(posts[post3.id]); + assert.ok(posts[post3a.id]); - done(); - } else if (getRequest.status === RequestStatus.FAILURE) { - done(new Error(JSON.stringify(getRequest.error))); + shouldStop = true; + done(); + } } }); @@ -314,24 +329,30 @@ describe('Actions.Posts', () => { {...TestHelper.fakePost(channelId), root_id: post3.id} ); + let shouldStop = false; store.subscribe(() => { const state = store.getState(); const getRequest = state.requests.posts.getPostsSince; const {posts, postsByChannel} = state.entities.posts; - if (getRequest.status === RequestStatus.SUCCESS) { - assert.ok(posts); - assert.ok(postsByChannel); + if (!shouldStop && + (getRequest.status === RequestStatus.SUCCESS || getRequest.status === RequestStatus.FAILURE)) { + if (getRequest.error) { + shouldStop = true; + done(new Error(JSON.stringify(getRequest.error))); + } else { + assert.ok(posts); + assert.ok(postsByChannel); - const postsInChannel = postsByChannel[channelId]; - assert.ok(postsInChannel); - assert.equal(postsInChannel[0], post3a.id, 'wrong order for post3a'); - assert.equal(postsInChannel[1], post3.id, 'wrong order for post3'); - assert.equal(postsInChannel.length, 2, 'wrong size'); + const postsInChannel = postsByChannel[channelId]; + assert.ok(postsInChannel); + assert.equal(postsInChannel[0], post3a.id, 'wrong order for post3a'); + assert.equal(postsInChannel[1], post3.id, 'wrong order for post3'); + assert.equal(postsInChannel.length, 2, 'wrong size'); - done(); - } else if (getRequest.status === RequestStatus.FAILURE) { - done(new Error(JSON.stringify(getRequest.error))); + shouldStop = true; + done(); + } } }); @@ -371,24 +392,30 @@ describe('Actions.Posts', () => { {...TestHelper.fakePost(channelId), root_id: post3.id} ); + let shouldStop = false; store.subscribe(() => { const state = store.getState(); const getRequest = state.requests.posts.getPostsBefore; const {posts, postsByChannel} = state.entities.posts; - if (getRequest.status === RequestStatus.SUCCESS) { - assert.ok(posts); - assert.ok(postsByChannel); + if (!shouldStop && + (getRequest.status === RequestStatus.SUCCESS || getRequest.status === RequestStatus.FAILURE)) { + if (getRequest.error) { + shouldStop = true; + done(new Error(JSON.stringify(getRequest.error))); + } else { + assert.ok(posts); + assert.ok(postsByChannel); - const postsInChannel = postsByChannel[channelId]; - assert.ok(postsInChannel); - assert.equal(postsInChannel[0], post1a.id, 'wrong order for post1a'); - assert.equal(postsInChannel[1], post1.id, 'wrong order for post1'); - assert.equal(postsInChannel.length, 3, 'wrong size'); + const postsInChannel = postsByChannel[channelId]; + assert.ok(postsInChannel); + assert.equal(postsInChannel[0], post1a.id, 'wrong order for post1a'); + assert.equal(postsInChannel[1], post1.id, 'wrong order for post1'); + assert.equal(postsInChannel.length, 3, 'wrong size'); - done(); - } else if (getRequest.status === RequestStatus.FAILURE) { - done(new Error(JSON.stringify(getRequest.error))); + shouldStop = true; + done(); + } } }); @@ -430,24 +457,30 @@ describe('Actions.Posts', () => { {...TestHelper.fakePost(channelId), root_id: post3.id} ); + let shouldStop = false; store.subscribe(() => { const state = store.getState(); const getRequest = state.requests.posts.getPostsAfter; const {posts, postsByChannel} = state.entities.posts; - if (getRequest.status === RequestStatus.SUCCESS) { - assert.ok(posts); - assert.ok(postsByChannel); + if (!shouldStop && + (getRequest.status === RequestStatus.SUCCESS || getRequest.status === RequestStatus.FAILURE)) { + if (getRequest.error) { + shouldStop = true; + done(new Error(JSON.stringify(getRequest.error))); + } else { + assert.ok(posts); + assert.ok(postsByChannel); - const postsInChannel = postsByChannel[channelId]; - assert.ok(postsInChannel); - assert.equal(postsInChannel[0], post3a.id, 'wrong order for post3a'); - assert.equal(postsInChannel[1], post3.id, 'wrong order for post3'); - assert.equal(postsInChannel.length, 2, 'wrong size'); + const postsInChannel = postsByChannel[channelId]; + assert.ok(postsInChannel); + assert.equal(postsInChannel[0], post3a.id, 'wrong order for post3a'); + assert.equal(postsInChannel[1], post3.id, 'wrong order for post3'); + assert.equal(postsInChannel.length, 2, 'wrong size'); - done(); - } else if (getRequest.status === RequestStatus.FAILURE) { - done(new Error(JSON.stringify(getRequest.error))); + shouldStop = true; + done(); + } } }); diff --git a/test/service/actions/teams.test.js b/test/service/actions/teams.test.js index b923b477e..489e25bd1 100644 --- a/test/service/actions/teams.test.js +++ b/test/service/actions/teams.test.js @@ -141,29 +141,28 @@ describe('Actions.Teams', () => { }); }); - // TODO: uncomment when PLT-4167 is merged - // it('getMyTeamMembers', (done) => { - // TestHelper.initBasic(Client).then(() => { - // const store = configureStore(); - // - // store.subscribe(() => { - // const membersRequest = store.getState().requests.teams.getMyTeamMembers; - // const members = store.getState().entities.teams.myMembers; - // - // if (membersRequest.status === RequestStatus.SUCCESS || membersRequest.status === RequestStatus.FAILURE) { - // if (membersRequest.error) { - // done(new Error(JSON.stringify(membersRequest.error))); - // } else { - // assert.ok(members); - // assert.ok(members[TestHelper.basicTeam.id]); - // done(); - // } - // } - // }); - // - // Actions.getMyTeamMembers()(store.dispatch, store.getState); - // }); - // }); + it('getMyTeamMembers', (done) => { + TestHelper.initBasic(Client).then(() => { + const store = configureStore(); + + store.subscribe(() => { + const membersRequest = store.getState().requests.teams.getMyTeamMembers; + const members = store.getState().entities.teams.myMembers; + + if (membersRequest.status === RequestStatus.SUCCESS || membersRequest.status === RequestStatus.FAILURE) { + if (membersRequest.error) { + done(new Error(JSON.stringify(membersRequest.error))); + } else { + assert.ok(members); + assert.ok(members[TestHelper.basicTeam.id]); + done(); + } + } + }); + + Actions.getMyTeamMembers()(store.dispatch, store.getState); + }); + }); it('getTeamMember', (done) => { TestHelper.initBasic(Client).then(async () => { @@ -193,7 +192,7 @@ describe('Actions.Teams', () => { Actions.getTeamMember(TestHelper.basicTeam.id, user.id)(store.dispatch, store.getState); }); - }); + }).timeout(3000); it('getTeamMembersByIds', (done) => { TestHelper.initBasic(Client).then(async () => { diff --git a/test/service/actions/users.test.js b/test/service/actions/users.test.js index 21b2f0bcf..5aa5685c1 100644 --- a/test/service/actions/users.test.js +++ b/test/service/actions/users.test.js @@ -113,7 +113,7 @@ describe('Actions.Users', () => { it('getProfiles', (done) => { TestHelper.initBasic(Client).then(async () => { const store = configureStore(); - const user = await TestHelper.basicClient.createUser(TestHelper.fakeUser()); + await TestHelper.basicClient.createUser(TestHelper.fakeUser()); store.subscribe(() => { const profilesRequest = store.getState().requests.users.getProfiles; @@ -123,7 +123,7 @@ describe('Actions.Users', () => { if (profilesRequest.error) { done(new Error(JSON.stringify(profilesRequest.error))); } else { - assert.ok(profiles[user.id]); + assert.ok(Object.keys(profiles).length); done(); } @@ -215,7 +215,7 @@ describe('Actions.Users', () => { } }); - Actions.getProfilesNotInChannel(TestHelper.basicTeam.id, TestHelper.basicChannel.id, 0)(store.dispatch, store.getState); + Actions.getProfilesNotInChannel(TestHelper.basicTeam.id, TestHelper.basicChannel.id, 0, 500)(store.dispatch, store.getState); }); }); diff --git a/test/service/actions/websocket.test.js b/test/service/actions/websocket.test.js new file mode 100644 index 000000000..ca925be7b --- /dev/null +++ b/test/service/actions/websocket.test.js @@ -0,0 +1,298 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. +import assert from 'assert'; +import * as Actions from 'service/actions/websocket'; +import * as ChannelActions from 'service/actions/channels'; +import * as TeamActions from 'service/actions/teams'; +import * as RootActions from 'app/actions/views/root'; +import Client from 'service/client'; +import configureStore from 'app/store'; +import {Constants, RequestStatus} from 'service/constants'; +import TestHelper from 'test/test_helper'; + +describe('Actions.Websocket', () => { + let store; + beforeEach(async () => { + store = configureStore(); + await TestHelper.initBasic(Client); + return await Actions.init()(store.dispatch, store.getState); + }); + + afterEach(() => { + Actions.close()(); + }); + + it('WebSocket Connect', () => { + const ws = store.getState().requests.general.websocket; + assert.ok(ws.status === RequestStatus.SUCCESS); + }); + + it('Websocket Handle New Post', async () => { + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + await client.login(user.email, 'password1'); + + await Client.addChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id); + + const post = {...TestHelper.fakePost(), channel_id: TestHelper.basicChannel.id}; + await client.createPost(TestHelper.basicTeam.id, post); + + const entities = store.getState().entities; + const {posts, postsByChannel} = entities.posts; + const channelId = TestHelper.basicChannel.id; + const postId = postsByChannel[channelId][0]; + + assert.ok(posts[postId].message.indexOf('Unit Test') > -1); + }); + + it('Websocket Handle Post Edited', async () => { + let post = {...TestHelper.fakePost(), channel_id: TestHelper.basicChannel.id}; + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await Client.addChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id); + + await client.login(user.email, 'password1'); + + post = await client.createPost(TestHelper.basicTeam.id, post); + post.message += ' (edited)'; + + await client.editPost(TestHelper.basicTeam.id, post); + + store.subscribe(async () => { + const entities = store.getState().entities; + const {posts} = entities.posts; + assert.ok(posts[post.id].message.indexOf('(edited)') > -1); + }); + }); + + it('Websocket Handle Post Deleted', async () => { + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await Client.addChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id); + + await client.login(user.email, 'password1'); + let post = TestHelper.fakePost(); + post.channel_id = TestHelper.basicChannel.id; + post = await client.createPost(TestHelper.basicTeam.id, post); + + await client.deletePost(TestHelper.basicTeam.id, post.channel_id, post.id); + + store.subscribe(async () => { + const entities = store.getState().entities; + const {posts} = entities.posts; + assert.strictEqual(posts[post.id].state, Constants.POST_DELETED); + }); + }); + + it('WebSocket Leave Team', async () => { + const client = TestHelper.createClient(); + const user = await client.createUser(TestHelper.fakeUser()); + await client.login(user.email, 'password1'); + const team = await client.createTeam(TestHelper.fakeTeam()); + const channel = await client.createChannel(TestHelper.fakeChannel(team.id)); + await client.addUserToTeam(team.id, TestHelper.basicUser.id); + await client.addChannelMember(team.id, channel.id, TestHelper.basicUser.id); + + await RootActions.setStoreFromLocalData({url: Client.getUrl(), token: Client.getToken()})(store.dispatch, store.getState); + await TeamActions.selectTeam(team)(store.dispatch, store.getState); + await ChannelActions.selectChannel(channel.id)(store.dispatch, store.getState); + await client.removeUserFromTeam(team.id, TestHelper.basicUser.id); + + const {currentId} = store.getState().entities.teams; + assert.strictEqual(currentId, ''); + }); + + it('Websocket Handle User Added', async () => { + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await TeamActions.selectTeam(TestHelper.basicTeam)(store.dispatch, store.getState); + await RootActions.setStoreFromLocalData({ + url: Client.getUrl(), + token: Client.getToken() + })(store.dispatch, store.getState); + + await ChannelActions.addChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + user.id + )(store.dispatch, store.getState); + + const entities = store.getState().entities; + const profilesInChannel = entities.users.profilesInChannel; + assert.ok(profilesInChannel[TestHelper.basicChannel.id].has(user.id)); + }); + + it('Websocket Handle User Removed', async () => { + await TeamActions.selectTeam(TestHelper.basicTeam)(store.dispatch, store.getState); + await RootActions.setStoreFromLocalData({ + url: Client.getUrl(), + token: Client.getToken() + })(store.dispatch, store.getState); + + await ChannelActions.removeChannelMember( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + TestHelper.basicUser.id + )(store.dispatch, store.getState); + + const state = store.getState(); + const entities = state.entities; + const profilesNotInChannel = entities.users.profilesNotInChannel; + + assert.ok(profilesNotInChannel[TestHelper.basicChannel.id].has(TestHelper.basicUser.id)); + }); + + it('Websocket Handle User Updated', async () => { + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + + await client.login(user.email, 'password1'); + await client.updateUser({...user, first_name: 'tester4'}); + + store.subscribe(() => { + const state = store.getState(); + const entities = state.entities; + const profiles = entities.users.profiles; + + assert.strictEqual(profiles[user.id].first_name, 'tester4'); + }); + }); + + it('Websocket Handle Channel Viewed', (done) => { + async function test() { + await TeamActions.selectTeam(TestHelper.basicTeam)(store.dispatch, store.getState); + await RootActions.setStoreFromLocalData({ + url: Client.getUrl(), + token: Client.getToken() + })(store.dispatch, store.getState); + + await Client.updateLastViewedAt( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id, + false + ); + + setTimeout(() => { + const state = store.getState(); + const entities = state.entities; + const {channels} = entities.channels; + assert.ok(channels[TestHelper.basicChannel.id]); + done(); + }, 500); + } + + test(); + }); + + it('Websocket Handle Channel Deleted', (done) => { + async function test() { + await ChannelActions.fetchMyChannelsAndMembers(TestHelper.basicTeam.id)(store.dispatch, store.getState); + await TeamActions.selectTeam(TestHelper.basicTeam)(store.dispatch, store.getState); + await ChannelActions.selectChannel(TestHelper.basicChannel.id)(store.dispatch, store.getState); + await Client.deleteChannel( + TestHelper.basicTeam.id, + TestHelper.basicChannel.id + ); + + setTimeout(() => { + const state = store.getState(); + const entities = state.entities; + const {channels, currentId} = entities.channels; + + assert.ok(channels[currentId].name === Constants.DEFAULT_CHANNEL); + done(); + }, 500); + } + + test(); + }); + + it('Websocket Handle Direct Channel', (done) => { + // TODO: Uncomment once fixed on platform + // const test = async () => { + // const client = TestHelper.createClient(); + // const user = await client.createUserWithInvite( + // TestHelper.fakeUser(), + // null, + // null, + // TestHelper.basicTeam.invite_id + // ); + // + // await client.login(user.email, 'password1'); + // await TeamActions.selectTeam(TestHelper.basicTeam)(store.dispatch, store.getState); + // + // store.subscribe(() => { + // const entities = store.getState().entities; + // const {channels} = entities.channels; + // assert.ok(Object.keys(channels).length); + // done(); + // }); + // + // await client.createDirectChannel(TestHelper.basicTeam.id, TestHelper.basicUser.id); + // }; + // test(); + done(); + }); + + it('Websocket Handle Preferences Changed', (done) => { + async function test() { + const client = TestHelper.createClient(); + const user = await client.createUserWithInvite( + TestHelper.fakeUser(), + null, + null, + TestHelper.basicTeam.invite_id + ); + await client.login(user.email, 'password1'); + const dm = await client.createDirectChannel(TestHelper.basicTeam.id, TestHelper.basicUser.id); + const post = {...TestHelper.fakePost(), channel_id: dm.id}; + await client.createPost(TestHelper.basicTeam.id, post); + + setTimeout(() => { + const entities = store.getState().entities; + const preferences = entities.preferences.myPreferences; + assert.ok(preferences[`${Constants.CATEGORY_DIRECT_CHANNEL_SHOW}--${user.id}`]); + done(); + }, 500); + } + + test(); + }); +});