From a6dd6e65ff7609c4a68ee1d07a2cdafb1abcd14f Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Thu, 23 Jul 2020 13:19:54 -0400 Subject: [PATCH] MM-24895 Load team member and roles in the WebSocket event (#4603) * Load team member and roles in the WebSocket event * Split WebSocket actions and events into multiple files --- app/actions/websocket.test.js | 1094 ----------------- app/actions/websocket.ts | 1240 -------------------- app/actions/websocket/channels.test.js | 179 +++ app/actions/websocket/channels.ts | 238 ++++ app/actions/websocket/general.test.js | 60 + app/actions/websocket/general.ts | 27 + app/actions/websocket/groups.ts | 23 + app/actions/websocket/index.ts | 403 +++++++ app/actions/websocket/integrations.test.js | 47 + app/actions/websocket/integrations.ts | 14 + app/actions/websocket/posts.test.js | 265 +++++ app/actions/websocket/posts.ts | 209 ++++ app/actions/websocket/preferences.ts | 60 + app/actions/websocket/reactions.test.js | 60 + app/actions/websocket/reactions.ts | 41 + app/actions/websocket/roles.ts | 33 + app/actions/websocket/teams.test.js | 106 ++ app/actions/websocket/teams.ts | 106 ++ app/actions/websocket/users.test.js | 94 ++ app/actions/websocket/users.ts | 204 ++++ app/actions/websocket/websocket.test.js | 547 +++++++++ app/mm-redux/actions/helpers.ts | 31 +- app/mm-redux/types/websocket.ts | 18 + 23 files changed, 2764 insertions(+), 2335 deletions(-) delete mode 100644 app/actions/websocket.test.js delete mode 100644 app/actions/websocket.ts create mode 100644 app/actions/websocket/channels.test.js create mode 100644 app/actions/websocket/channels.ts create mode 100644 app/actions/websocket/general.test.js create mode 100644 app/actions/websocket/general.ts create mode 100644 app/actions/websocket/groups.ts create mode 100644 app/actions/websocket/index.ts create mode 100644 app/actions/websocket/integrations.test.js create mode 100644 app/actions/websocket/integrations.ts create mode 100644 app/actions/websocket/posts.test.js create mode 100644 app/actions/websocket/posts.ts create mode 100644 app/actions/websocket/preferences.ts create mode 100644 app/actions/websocket/reactions.test.js create mode 100644 app/actions/websocket/reactions.ts create mode 100644 app/actions/websocket/roles.ts create mode 100644 app/actions/websocket/teams.test.js create mode 100644 app/actions/websocket/teams.ts create mode 100644 app/actions/websocket/users.test.js create mode 100644 app/actions/websocket/users.ts create mode 100644 app/actions/websocket/websocket.test.js create mode 100644 app/mm-redux/types/websocket.ts diff --git a/app/actions/websocket.test.js b/app/actions/websocket.test.js deleted file mode 100644 index a81e5339a..000000000 --- a/app/actions/websocket.test.js +++ /dev/null @@ -1,1094 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -/* eslint-disable no-import-assign */ - -import assert from 'assert'; -import nock from 'nock'; -import {Server, WebSocket as MockWebSocket} from 'mock-socket'; -import {batchActions} from 'redux-batched-actions'; -import thunk from 'redux-thunk'; -import configureMockStore from 'redux-mock-store'; - -import {ChannelTypes, GeneralTypes, RoleTypes, TeamTypes, UserTypes} from '@mm-redux/action_types'; -import * as ChannelActions from '@mm-redux/actions/channels'; -import * as PostActions from '@mm-redux/actions/posts'; -import * as TeamActions from '@mm-redux/actions/teams'; -import {Client4} from '@mm-redux/client'; -import {General, Posts, RequestStatus} from '@mm-redux/constants'; -import * as PostSelectors from '@mm-redux/selectors/entities/posts'; -import EventEmitter from '@mm-redux/utils/event_emitter'; - -import * as Actions from '@actions/websocket'; -import {WebsocketEvents} from '@constants'; -import globalInitialState from '@store/initial_state'; - -import TestHelper from 'test/test_helper'; -import configureStore from 'test/test_store'; - -global.WebSocket = MockWebSocket; - -const mockConfigRequest = (config = {}) => { - nock(Client4.getBaseRoute()). - get('/config/client?format=old'). - reply(200, config); -}; - -const mockChanelsRequest = (teamId, channels = []) => { - nock(Client4.getUserRoute('me')). - get(`/teams/${teamId}/channels?include_deleted=true`). - reply(200, channels); -}; - -const mockGetKnownUsersRequest = (userIds = []) => { - nock(Client4.getBaseRoute()). - get('/users/known'). - reply(200, userIds); -}; - -const mockRolesRequest = (rolesToLoad = []) => { - nock(Client4.getRolesRoute()). - post('/names', JSON.stringify(rolesToLoad)). - reply(200, rolesToLoad); -}; - -const mockTeamMemberRequest = (tm = []) => { - nock(Client4.getUserRoute('me')). - log(console.log). - get('/teams/members'). - reply(200, tm); -}; - -describe('Actions.Websocket', () => { - let store; - let mockServer; - beforeAll(async () => { - store = await configureStore(); - await TestHelper.initBasic(Client4); - - const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:'); - mockServer = new Server(connUrl); - return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); - }); - - afterAll(async () => { - Actions.close()(); - mockServer.stop(); - await TestHelper.tearDown(); - }); - - it('WebSocket Connect', () => { - const ws = store.getState().requests.general.websocket; - assert.ok(ws.status === RequestStatus.SUCCESS); - }); - - it('Websocket Handle New Post if post does not exist', async () => { - PostSelectors.getPost = jest.fn(); - const channelId = TestHelper.basicChannel.id; - const message = JSON.stringify({event: WebsocketEvents.POSTED, data: {channel_display_name: TestHelper.basicChannel.display_name, channel_name: TestHelper.basicChannel.name, channel_type: 'O', post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w", "create_at": 1508245311774, "update_at": 1508245311774, "edit_at": 0, "delete_at": 0, "is_pinned": false, "user_id": "${TestHelper.basicUser.id}", "channel_id": "${channelId}", "root_id": "", "parent_id": "", "original_id": "", "message": "Unit Test", "type": "", "props": {}, "hashtags": "", "pending_post_id": "t36kso9nwtdhbm8dbkd6g4eeby: 1508245311749"}`, sender_name: TestHelper.basicUser.username, team_id: TestHelper.basicTeam.id}, broadcast: {omit_users: null, user_id: '', channel_id: channelId, team_id: ''}, seq: 2}); - - nock(Client4.getBaseRoute()). - post('/users/ids'). - reply(200, [TestHelper.basicUser.id]); - - nock(Client4.getBaseRoute()). - post('/users/status/ids'). - reply(200, [{user_id: TestHelper.basicUser.id, status: 'online', manual: false, last_activity_at: 1507662212199}]); - - // Mock that post already exists and check it is not added - PostSelectors.getPost.mockReturnValueOnce(true); - mockServer.emit('message', message); - let entities = store.getState().entities; - let posts = entities.posts.posts; - assert.deepEqual(posts, {}); - - // Mock that post does not exist and check it is added - PostSelectors.getPost.mockReturnValueOnce(false); - mockServer.emit('message', message); - await TestHelper.wait(100); - entities = store.getState().entities; - posts = entities.posts.posts; - const postId = Object.keys(posts)[0]; - assert.ok(posts[postId].message.indexOf('Unit Test') > -1); - entities = store.getState().entities; - }); - - it('Websocket Handle New Post emits INCREASE_POST_VISIBILITY_BY_ONE for current channel when post does not exist', async () => { - PostSelectors.getPost = jest.fn(); - const emit = jest.spyOn(EventEmitter, 'emit'); - const currentChannelId = TestHelper.generateId(); - const otherChannelId = TestHelper.generateId(); - const messageFor = (channelId) => ({event: WebsocketEvents.POSTED, data: {channel_display_name: TestHelper.basicChannel.display_name, channel_name: TestHelper.basicChannel.name, channel_type: 'O', post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w", "create_at": 1508245311774, "update_at": 1508245311774, "edit_at": 0, "delete_at": 0, "is_pinned": false, "user_id": "${TestHelper.basicUser.id}", "channel_id": "${channelId}", "root_id": "", "parent_id": "", "original_id": "", "message": "Unit Test", "type": "", "props": {}, "hashtags": "", "pending_post_id": "t36kso9nwtdhbm8dbkd6g4eeby: 1508245311749"}`, sender_name: TestHelper.basicUser.username, team_id: TestHelper.basicTeam.id}, broadcast: {omit_users: null, user_id: '', channel_id: channelId, team_id: ''}, seq: 2}); - - await store.dispatch(ChannelActions.selectChannel(currentChannelId)); - await TestHelper.wait(100); - - // Post does not exist and is not for current channel - PostSelectors.getPost.mockReturnValueOnce(false); - mockServer.emit('message', JSON.stringify(messageFor(otherChannelId))); - expect(emit).not.toHaveBeenCalled(); - - // Post exists and is not for current channel - PostSelectors.getPost.mockReturnValueOnce(true); - mockServer.emit('message', JSON.stringify(messageFor(otherChannelId))); - expect(emit).not.toHaveBeenCalled(); - - // Post exists and is for current channel - PostSelectors.getPost.mockReturnValueOnce(true); - mockServer.emit('message', JSON.stringify(messageFor(currentChannelId))); - expect(emit).not.toHaveBeenCalled(); - - // Post does not exist and is for current channel - PostSelectors.getPost.mockReturnValueOnce(false); - mockServer.emit('message', JSON.stringify(messageFor(currentChannelId))); - expect(emit).toHaveBeenCalledWith(WebsocketEvents.INCREASE_POST_VISIBILITY_BY_ONE); - }); - - it('Websocket Handle New Post if status is manually set do not set to online', async () => { - const userId = TestHelper.generateId(); - - store = await configureStore({ - entities: { - users: { - statuses: { - [userId]: General.DND, - }, - isManualStatus: { - [userId]: true, - }, - }, - }, - }); - await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); - - const channelId = TestHelper.basicChannel.id; - const message = JSON.stringify({ - event: WebsocketEvents.POSTED, - data: { - channel_display_name: TestHelper.basicChannel.display_name, - channel_name: TestHelper.basicChannel.name, - channel_type: 'O', - post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w", "create_at": 1508245311774, "update_at": 1508245311774, "edit_at": 0, "delete_at": 0, "is_pinned": false, "user_id": "${userId}", "channel_id": "${channelId}", "root_id": "", "parent_id": "", "original_id": "", "message": "Unit Test", "type": "", "props": {}, "hashtags": "", "pending_post_id": "t36kso9nwtdhbm8dbkd6g4eeby: 1508245311749"}`, - sender_name: TestHelper.basicUser.username, - team_id: TestHelper.basicTeam.id, - }, - broadcast: { - omit_users: null, - user_id: userId, - channel_id: channelId, - team_id: '', - }, - seq: 2, - }); - - mockServer.emit('message', message); - const entities = store.getState().entities; - const statuses = entities.users.statuses; - assert.equal(statuses[userId], General.DND); - }); - - it('Websocket Handle Post Edited', async () => { - const post = {id: '71k8gz5ompbpfkrzaxzodffj8w'}; - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.POST_EDITED, data: {post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w","create_at": 1508245311774,"update_at": 1585236976007,"edit_at": 1585236976007,"delete_at": 0,"is_pinned": false,"user_id": "${TestHelper.basicUser.id}","channel_id": "${TestHelper.basicChannel.id}","root_id": "","parent_id": "","original_id": "","message": "Unit Test (edited)","type": "","props": {},"hashtags": "","pending_post_id": ""}`}, broadcast: {omit_users: null, user_id: '', channel_id: '18k9ffsuci8xxm7ak68zfdyrce', team_id: ''}, seq: 2})); - - await TestHelper.wait(300); - - const {posts} = store.getState().entities.posts; - assert.ok(posts); - assert.ok(posts[post.id]); - assert.ok(posts[post.id].message.indexOf('(edited)') > -1); - }); - - it('Websocket Handle Post Deleted', async () => { - const post = TestHelper.fakePost(); - post.channel_id = TestHelper.basicChannel.id; - - post.id = '71k8gz5ompbpfkrzaxzodffj8w'; - store.dispatch(PostActions.receivedPost(post)); - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.POST_DELETED, data: {post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w","create_at": 1508245311774,"update_at": 1508247709215,"edit_at": 1508247709215,"delete_at": 0,"is_pinned": false,"user_id": "${TestHelper.basicUser.id}","channel_id": "${post.channel_id}","root_id": "","parent_id": "","original_id": "","message": "Unit Test","type": "","props": {},"hashtags": "","pending_post_id": ""}`}, broadcast: {omit_users: null, user_id: '', channel_id: '18k9ffsuci8xxm7ak68zfdyrce', team_id: ''}, seq: 7})); - - const entities = store.getState().entities; - const {posts} = entities.posts; - assert.strictEqual(posts[post.id].state, Posts.POST_DELETED); - }); - - it('Websocket handle Post Unread', async () => { - const teamId = TestHelper.generateId(); - const channelId = TestHelper.generateId(); - const userId = TestHelper.generateId(); - - store = await configureStore({ - entities: { - channels: { - channels: { - [channelId]: {id: channelId}, - }, - myMembers: { - [channelId]: {msg_count: 10, mention_count: 0, last_viewed_at: 0}, - }, - }, - teams: { - myMembers: { - [teamId]: {msg_count: 10, mention_count: 0}, - }, - }, - }, - }); - await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); - - mockServer.emit('message', JSON.stringify({ - event: WebsocketEvents.POST_UNREAD, - data: { - last_viewed_at: 25, - msg_count: 3, - mention_count: 2, - delta_msg: 7, - }, - broadcast: {omit_users: null, user_id: userId, channel_id: channelId, team_id: teamId}, - seq: 7, - })); - - const state = store.getState(); - assert.equal(state.entities.channels.manuallyUnread[channelId], true); - assert.equal(state.entities.channels.myMembers[channelId].msg_count, 3); - assert.equal(state.entities.channels.myMembers[channelId].mention_count, 2); - assert.equal(state.entities.channels.myMembers[channelId].last_viewed_at, 25); - assert.equal(state.entities.teams.myMembers[teamId].msg_count, 3); - assert.equal(state.entities.teams.myMembers[teamId].mention_count, 2); - }); - - it('Websocket handle Post Unread When marked on the same client', async () => { - const teamId = TestHelper.generateId(); - const channelId = TestHelper.generateId(); - const userId = TestHelper.generateId(); - - store = await configureStore({ - entities: { - channels: { - channels: { - [channelId]: {id: channelId}, - }, - myMembers: { - [channelId]: {msg_count: 5, mention_count: 4, last_viewed_at: 14}, - }, - manuallyUnread: { - [channelId]: true, - }, - }, - teams: { - myMembers: { - [teamId]: {msg_count: 5, mention_count: 4}, - }, - }, - }, - }); - await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); - - mockServer.emit('message', JSON.stringify({ - event: WebsocketEvents.POST_UNREAD, - data: { - last_viewed_at: 25, - msg_count: 5, - mention_count: 4, - delta_msg: 1, - }, - broadcast: {omit_users: null, user_id: userId, channel_id: channelId, team_id: teamId}, - seq: 17, - })); - - const state = store.getState(); - assert.equal(state.entities.channels.manuallyUnread[channelId], true); - assert.equal(state.entities.channels.myMembers[channelId].msg_count, 5); - assert.equal(state.entities.channels.myMembers[channelId].mention_count, 4); - assert.equal(state.entities.channels.myMembers[channelId].last_viewed_at, 14); - assert.equal(state.entities.teams.myMembers[teamId].msg_count, 5); - assert.equal(state.entities.teams.myMembers[teamId].mention_count, 4); - }); - - it('Websocket Handle Reaction Added to Post', async () => { - const emoji = '+1'; - const post = {id: 'w7yo9377zbfi9mgiq5gbfpn3ha'}; - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.REACTION_ADDED, data: {reaction: `{"user_id":"${TestHelper.basicUser.id}","post_id":"w7yo9377zbfi9mgiq5gbfpn3ha","emoji_name":"${emoji}","create_at":1508249125852}`}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 12})); - - await TestHelper.wait(300); - const nextEntities = store.getState().entities; - const {reactions} = nextEntities.posts; - const reactionsForPost = reactions[post.id]; - - assert.ok(reactionsForPost.hasOwnProperty(`${TestHelper.basicUser.id}-${emoji}`)); - }); - - // If we move this test lower it will fail cause of a permissions issue - it('Websocket handle team updated', async () => { - const team = {id: '55pfercbm7bsmd11p5cjpgsbwr'}; - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.UPDATE_TEAM, data: {team: `{"id":"55pfercbm7bsmd11p5cjpgsbwr","create_at":1495553950859,"update_at":1508250370054,"delete_at":0,"display_name":"${TestHelper.basicTeam.display_name}","name":"${TestHelper.basicTeam.name}","description":"description","email":"","type":"O","company_name":"","allowed_domains":"","invite_id":"m93f54fu5bfntewp8ctwonw19w","allow_open_invite":true}`}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 26})); - - await TestHelper.wait(300); - - const entities = store.getState().entities; - const {teams} = entities.teams; - const updated = teams[team.id]; - assert.ok(updated); - assert.strictEqual(updated.allow_open_invite, true); - }); - - it('Websocket handle team patched', async () => { - const team = {id: '55pfercbm7bsmd11p5cjpgsbwr'}; - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.UPDATE_TEAM, data: {team: `{"id":"55pfercbm7bsmd11p5cjpgsbwr","create_at":1495553950859,"update_at":1508250370054,"delete_at":0,"display_name":"${TestHelper.basicTeam.display_name}","name":"${TestHelper.basicTeam.name}","description":"description","email":"","type":"O","company_name":"","allowed_domains":"","invite_id":"m93f54fu5bfntewp8ctwonw19w","allow_open_invite":true}`}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 26})); - - await TestHelper.wait(300); - - const entities = store.getState().entities; - const {teams} = entities.teams; - const updated = teams[team.id]; - assert.ok(updated); - assert.strictEqual(updated.allow_open_invite, true); - }); - - it('WebSocket Leave Team', async () => { - const team = TestHelper.basicTeam; - store.dispatch(batchActions([ - {type: UserTypes.RECEIVED_ME, data: TestHelper.basicUser}, - {type: TeamTypes.RECEIVED_TEAM, data: TestHelper.basicTeam}, - {type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, data: TestHelper.basicTeamMember}, - ])); - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.LEAVE_TEAM, data: {team_id: team.id, user_id: TestHelper.basicUser.id}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: team.id}, seq: 35})); - - const {myMembers} = store.getState().entities.teams; - assert.ifError(myMembers[team.id]); - }); - - it('Websocket Handle User Added', async () => { - const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()}; - store.dispatch({type: UserTypes.RECEIVED_PROFILE_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}}); - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_ADDED, data: {team_id: TestHelper.basicTeam.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42})); - - 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 () => { - const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()}; - store.dispatch({type: UserTypes.RECEIVED_PROFILE_NOT_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}}); - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_REMOVED, data: {remover_id: TestHelper.basicUser.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42})); - - const state = store.getState(); - const entities = state.entities; - const profilesNotInChannel = entities.users.profilesNotInChannel; - - assert.ok(profilesNotInChannel[TestHelper.basicChannel.id].has(user.id)); - }); - - it('Websocket Handle User Removed when Current is Guest', async () => { - const basicGuestUser = TestHelper.fakeUserWithId(); - basicGuestUser.roles = 'system_guest'; - - const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()}; - - // add user first - store.dispatch({type: UserTypes.RECEIVED_PROFILE_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}}); - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_ADDED, data: {team_id: TestHelper.basicTeam.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42})); - - assert.ok(store.getState().entities.users.profilesInChannel[TestHelper.basicChannel.id].has(user.id)); - - // remove user - store.dispatch({type: UserTypes.RECEIVED_PROFILE_NOT_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}}); - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_REMOVED, data: {remover_id: basicGuestUser.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42})); - - assert.ok(!store.getState().entities.users.profilesInChannel[TestHelper.basicChannel.id].has(user.id)); - }); - - it('Websocket Handle User Updated', async () => { - const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()}; - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_UPDATED, data: {user: {id: user.id, create_at: 1495570297229, update_at: 1508253268652, delete_at: 0, username: 'tim', auth_data: '', auth_service: '', email: 'tim@bladekick.com', nickname: '', first_name: 'tester4', last_name: '', position: '', roles: 'system_user', locale: 'en'}}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 53})); - - 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 Member Updated', async () => { - const channelMember = TestHelper.basicChannelMember; - const mockStore = configureMockStore([thunk]); - const st = mockStore(globalInitialState); - await st.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); - channelMember.roles = 'channel_user channel_admin'; - const rolesToLoad = channelMember.roles.split(' '); - - nock(Client4.getRolesRoute()). - post('/names', JSON.stringify(rolesToLoad)). - reply(200, rolesToLoad); - - mockServer.emit('message', JSON.stringify({ - event: WebsocketEvents.CHANNEL_MEMBER_UPDATED, - data: { - channelMember: JSON.stringify(channelMember), - }, - })); - - await TestHelper.wait(300); - const storeActions = st.getActions(); - const batch = storeActions.find((a) => a.type === 'BATCH_WS_CHANNEL_MEMBER_UPDATE'); - expect(batch).not.toBeNull(); - const memberAction = batch.payload.find((a) => a.type === ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER); - expect(memberAction).not.toBeNull(); - const rolesActions = batch.payload.find((a) => a.type === RoleTypes.RECEIVED_ROLES); - expect(rolesActions).not.toBeNull(); - expect(rolesActions.data).toEqual(rolesToLoad); - }); - - it('Websocket Handle Channel Created', async () => { - const channelId = TestHelper.basicChannel.id; - const channel = {id: channelId, display_name: 'test', name: TestHelper.basicChannel.name}; - await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); - store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: channel}); - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CHANNEL_CREATED, data: {channel_id: channelId, team_id: TestHelper.basicTeam.id}, broadcast: {omit_users: null, user_id: 't36kso9nwtdhbm8dbkd6g4eeby', channel_id: '', team_id: ''}, seq: 57})); - - await TestHelper.wait(300); - - const state = store.getState(); - const entities = state.entities; - const {channels} = entities.channels; - - assert.ok(channels[channel.id]); - }); - - it('Websocket Handle Channel Updated', async () => { - const channelName = 'Test name'; - const channelId = TestHelper.basicChannel.id; - - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CHANNEL_UPDATED, data: {channel: `{"id":"${channelId}","create_at":1508253647983,"update_at":1508254198797,"delete_at":0,"team_id":"55pfercbm7bsmd11p5cjpgsbwr","type":"O","display_name":"${channelName}","name":"${TestHelper.basicChannel.name}","header":"header","purpose":"","last_post_at":1508253648004,"total_msg_count":0,"extra_update_at":1508253648001,"creator_id":"${TestHelper.basicUser.id}"}`}, broadcast: {omit_users: null, user_id: '', channel_id: channelId, team_id: ''}, seq: 62})); - - await TestHelper.wait(300); - - const state = store.getState(); - const entities = state.entities; - const {channels} = entities.channels; - - assert.strictEqual(channels[channelId].display_name, channelName); - }); - - it('Websocket Handle Channel Deleted', async () => { - const time = Date.now(); - await store.dispatch(TeamActions.selectTeam(TestHelper.basicTeam)); - await store.dispatch(ChannelActions.selectChannel(TestHelper.basicChannel.id)); - - store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: {id: TestHelper.generateId(), name: General.DEFAULT_CHANNEL, team_id: TestHelper.basicTeam.id, display_name: General.DEFAULT_CHANNEL}}); - store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: TestHelper.basicChannel}); - - nock(Client4.getUserRoute('me')). - get(`/teams/${TestHelper.basicTeam.id}/channels/members`). - reply(201, [{user_id: TestHelper.basicUser.id, channel_id: TestHelper.basicChannel.id}]); - - mockServer.emit('message', JSON.stringify({ - event: WebsocketEvents.CHANNEL_DELETED, - data: { - channel_id: TestHelper.basicChannel.id, - delete_at: time, - }, - broadcast: { - omit_users: null, - user_id: '', - channel_id: '', - team_id: TestHelper.basicTeam.id, - }, - seq: 68, - })); - - await TestHelper.wait(300); - - const state = store.getState(); - const entities = state.entities; - const {channels, currentChannelId} = entities.channels; - - assert.ok(channels[currentChannelId].name === General.DEFAULT_CHANNEL); - }); - - it('Websocket Handle Channel Unarchive', async () => { - await store.dispatch(TeamActions.selectTeam(TestHelper.basicTeam)); - await store.dispatch(ChannelActions.selectChannel(TestHelper.basicChannel.id)); - - store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: {id: TestHelper.generateId(), name: General.DEFAULT_CHANNEL, team_id: TestHelper.basicTeam.id, display_name: General.DEFAULT_CHANNEL}}); - store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: TestHelper.basicChannel}); - - nock(Client4.getUserRoute('me')). - get(`/teams/${TestHelper.basicTeam.id}/channels/members`). - reply(201, [{user_id: TestHelper.basicUser.id, channel_id: TestHelper.basicChannel.id}]); - - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CHANNEL_UNARCHIVE, data: {channel_id: TestHelper.basicChannel.id}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: TestHelper.basicTeam.id}, seq: 68})); - - await TestHelper.wait(300); - - const state = store.getState(); - const entities = state.entities; - const {channels, currentChannelId} = entities.channels; - - assert.ok(channels[currentChannelId].delete_at === 0); - }); - - it('Websocket Handle Direct Channel', async () => { - const channel = {id: TestHelper.generateId(), name: TestHelper.basicUser.id + '__' + TestHelper.generateId(), type: 'D'}; - - nock(Client4.getChannelsRoute()). - get(`/${channel.id}/members/me`). - reply(201, {user_id: TestHelper.basicUser.id, channel_id: channel.id}); - - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.DIRECT_ADDED, data: {teammate_id: 'btaxe5msnpnqurayosn5p8twuw'}, broadcast: {omit_users: null, user_id: '', channel_id: channel.id, team_id: ''}, seq: 2})); - store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: channel}); - - await TestHelper.wait(300); - - const {channels} = store.getState().entities.channels; - assert.ok(Object.keys(channels).length); - }); - - it('Websocket handle user added to team', async () => { - const team = {id: TestHelper.generateId()}; - - nock(Client4.getBaseRoute()). - get(`/teams/${team.id}`). - reply(200, team); - - nock(Client4.getBaseRoute()). - get('/users/me/teams/unread'). - reply(200, [{team_id: team.id, msg_count: 0, mention_count: 0}]); - - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.ADDED_TO_TEAM, data: {team_id: team.id, user_id: TestHelper.basicUser.id}, broadcast: {omit_users: null, user_id: TestHelper.basicUser.id, channel_id: '', team_id: ''}, seq: 2})); - - await TestHelper.wait(300); - - const {teams, myMembers} = store.getState().entities.teams; - assert.ok(teams[team.id]); - assert.ok(myMembers[team.id]); - - const member = myMembers[team.id]; - assert.ok(member.hasOwnProperty('mention_count')); - }); - - it('Websocket handle emoji added', async () => { - const created = {id: '1mmgakhhupfgfm8oug6pooc5no'}; - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.EMOJI_ADDED, data: {emoji: `{"id":"1mmgakhhupfgfm8oug6pooc5no","create_at":1508263941321,"update_at":1508263941321,"delete_at":0,"creator_id":"t36kso9nwtdhbm8dbkd6g4eeby","name":"${TestHelper.generateId()}"}`}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 2})); - - await TestHelper.wait(200); - - const state = store.getState(); - - const emojis = state.entities.emojis.customEmoji; - assert.ok(emojis); - assert.ok(emojis[created.id]); - }); - - it('handle license changed', async () => { - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.LICENSE_CHANGED, data: {license: {IsLicensed: 'true'}}})); - - await TestHelper.wait(200); - - const state = store.getState(); - - const license = state.entities.general.license; - assert.ok(license); - assert.ok(license.IsLicensed); - }); - - it('handle config changed', async () => { - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CONFIG_CHANGED, data: {config: {EnableCustomEmoji: 'true', EnableLinkPreviews: 'false'}}})); - - await TestHelper.wait(200); - - const state = store.getState(); - - const config = state.entities.general.config; - assert.ok(config); - assert.ok(config.EnableCustomEmoji === 'true'); - assert.ok(config.EnableLinkPreviews === 'false'); - }); - - it('handle open dialog', async () => { - mockServer.emit('message', JSON.stringify({event: WebsocketEvents.OPEN_DIALOG, data: {dialog: JSON.stringify({url: 'someurl', trigger_id: 'sometriggerid', dialog: {}})}})); - - await TestHelper.wait(200); - - const state = store.getState(); - - const dialog = state.entities.integrations.dialog; - assert.ok(dialog); - assert.ok(dialog.url === 'someurl'); - assert.ok(dialog.trigger_id === 'sometriggerid'); - assert.ok(dialog.dialog); - }); -}); - -describe('Actions.Websocket doReconnect', () => { - const mockStore = configureMockStore([thunk]); - const me = TestHelper.fakeUserWithId(); - const team = TestHelper.fakeTeamWithId(); - const teamMember = TestHelper.fakeTeamMember(me.id, team.id); - const channel1 = TestHelper.fakeChannelWithId(team.id); - const channel2 = TestHelper.fakeChannelWithId(team.id); - const cMember1 = TestHelper.fakeChannelMember(me.id, channel1.id); - const cMember2 = TestHelper.fakeChannelMember(me.id, channel2.id); - - const currentTeamId = team.id; - const currentUserId = me.id; - const currentChannelId = channel1.id; - - const initialState = { - entities: { - general: { - config: {}, - }, - teams: { - currentTeamId, - myMembers: { - [currentTeamId]: teamMember, - }, - teams: { - [currentTeamId]: team, - }, - }, - channels: { - currentChannelId, - channels: { - currentChannelId: channel1, - }, - }, - users: { - currentUserId, - profiles: { - [me.id]: me, - }, - }, - preferences: { - myPreferences: {}, - }, - posts: { - posts: {}, - postsInChannel: {}, - }, - }, - websocket: { - connected: false, - lastConnectAt: 0, - lastDisconnectAt: 0, - }, - }; - - beforeAll(async () => { - return TestHelper.initBasic(Client4); - }); - - beforeEach(() => { - nock(Client4.getBaseRoute()). - get('/users/me'). - reply(200, me); - - nock(Client4.getUserRoute('me')). - get('/teams'). - reply(200, [team]); - - nock(Client4.getUserRoute('me')). - get('/teams/unread'). - reply(200, [{id: team.id, msg_count: 0, mention_count: 0}]); - - nock(Client4.getBaseRoute()). - get('/users/me/preferences'). - reply(200, []); - - nock(Client4.getUserRoute('me')). - get(`/teams/${team.id}/channels/members`). - reply(200, [cMember1, cMember2]); - - nock(Client4.getChannelRoute(channel1.id)). - get(`/posts?page=0&per_page=${Posts.POST_CHUNK_SIZE}`). - reply(200, { - posts: { - post1: {id: 'post1', create_at: 0, message: 'hey'}, - }, - order: ['post1'], - }); - }); - - afterAll(async () => { - Actions.close()(); - await TestHelper.tearDown(); - }); - - it('handle doReconnect', async () => { - const state = {...initialState}; - const testStore = await mockStore(state); - const timestamp = 1000; - const expectedActions = [ - GeneralTypes.WEBSOCKET_SUCCESS, - 'BATCH_WS_RECONNECT', - 'BATCH_GET_POSTS', - ]; - - mockConfigRequest(); - mockTeamMemberRequest([teamMember]); - mockChanelsRequest(team.id, [channel1, channel2]); - - let rolesToLoad = Array.from(new Set(me.roles.split(' '). - concat(teamMember.roles.split(' ')))); - mockRolesRequest(rolesToLoad); - - rolesToLoad = Array.from(new Set(cMember1.roles.split(' '). - concat(cMember2.roles.split(' ')))); - mockRolesRequest(rolesToLoad); - - await testStore.dispatch(Actions.doReconnect(timestamp)); - await TestHelper.wait(300); - const actionTypes = testStore.getActions().map((a) => a.type); - expect(actionTypes).toEqual(expectedActions); - }); - - it('handle doReconnect after the current channel was archived or the user left it', async () => { - const state = { - ...initialState, - entities: { - ...initialState.entities, - channels: { - ...initialState.entities.channels, - currentChannelId: 'channel-3', - }, - }, - }; - const testStore = await mockStore(state); - const timestamp = 1000; - const expectedActions = [ - GeneralTypes.WEBSOCKET_SUCCESS, - 'BATCH_WS_RECONNECT', - ]; - const expectedMissingActions = [ - 'BATCH_GET_POSTS', - ]; - - mockConfigRequest(); - mockTeamMemberRequest([teamMember]); - mockChanelsRequest(team.id, [channel1, channel2]); - - let rolesToLoad = Array.from(new Set(me.roles.split(' '). - concat(teamMember.roles.split(' ')))); - mockRolesRequest(rolesToLoad); - - rolesToLoad = Array.from(new Set(cMember1.roles.split(' '). - concat(cMember2.roles.split(' ')))); - mockRolesRequest(rolesToLoad); - - await testStore.dispatch(Actions.doReconnect(timestamp)); - await TestHelper.wait(300); - - const actions = testStore.getActions().map((a) => a.type); - - expect(actions).toEqual(expect.arrayContaining(expectedActions)); - expect(actions).not.toEqual(expect.arrayContaining(expectedMissingActions)); - }); - - it('handle doReconnect after the current channel was archived and setting is on', async () => { - const archived = { - ...channel1, - delete_at: 123, - }; - const state = { - ...initialState, - channels: { - currentChannelId, - channels: { - currentChannelId: archived, - }, - }, - }; - const testStore = await mockStore(state); - const timestamp = 1000; - const expectedActions = [ - GeneralTypes.WEBSOCKET_SUCCESS, - 'BATCH_WS_RECONNECT', - ]; - - mockConfigRequest({ExperimentalViewArchivedChannels: 'true'}); - mockTeamMemberRequest([teamMember]); - mockChanelsRequest(team.id, [archived, channel2]); - - let rolesToLoad = Array.from(new Set(me.roles.split(' '). - concat(teamMember.roles.split(' ')))); - mockRolesRequest(rolesToLoad); - - rolesToLoad = Array.from(new Set(cMember1.roles.split(' '). - concat(cMember2.roles.split(' ')))); - mockRolesRequest(rolesToLoad); - - await testStore.dispatch(Actions.doReconnect(timestamp)); - await TestHelper.wait(300); - - const actions = testStore.getActions().map((a) => a.type); - expect(actions).toEqual(expect.arrayContaining(expectedActions)); - }); - - it('handle doReconnect after the current channel was archived and setting is off', async () => { - const archived = { - ...channel1, - delete_at: 123, - }; - - const state = { - ...initialState, - channels: { - currentChannelId, - channels: { - currentChannelId: archived, - }, - }, - }; - const testStore = await mockStore(state); - const timestamp = 1000; - const expectedActions = [ - GeneralTypes.WEBSOCKET_SUCCESS, - 'BATCH_WS_RECONNECT', - ]; - const expectedMissingActions = [ - 'BATCH_GET_POSTS', - ]; - - mockConfigRequest({ExperimentalViewArchivedChannels: 'false'}); - mockTeamMemberRequest([teamMember]); - mockChanelsRequest(team.id, [archived, channel2]); - - let rolesToLoad = Array.from(new Set(me.roles.split(' '). - concat(teamMember.roles.split(' ')))); - mockRolesRequest(rolesToLoad); - - rolesToLoad = Array.from(new Set(cMember1.roles.split(' '). - concat(cMember2.roles.split(' ')))); - mockRolesRequest(rolesToLoad); - - await testStore.dispatch(Actions.doReconnect(timestamp)); - await TestHelper.wait(300); - - const actions = testStore.getActions().map((a) => a.type); - expect(actions).toEqual(expectedActions); - expect(actions).not.toEqual(expect.arrayContaining(expectedMissingActions)); - }); - - it('handle doReconnect after user left current team', async () => { - const state = {...initialState}; - state.entities.teams.myMembers = {}; - const testStore = await mockStore(state); - const timestamp = 1000; - const expectedActions = [ - GeneralTypes.WEBSOCKET_SUCCESS, - 'BATCH_WS_LEAVE_TEAM', - 'BATCH_WS_RECONNECT', - ]; - const expectedMissingActions = [ - 'BATCH_GET_POSTS', - ]; - - mockConfigRequest(); - mockTeamMemberRequest([]); - mockChanelsRequest(team.id, [channel1, channel2]); - - let rolesToLoad = me.roles.split(' '); - mockRolesRequest(rolesToLoad); - - rolesToLoad = Array.from(new Set(cMember1.roles.split(' '). - concat(cMember2.roles.split(' ')))); - mockRolesRequest(rolesToLoad); - - await testStore.dispatch(Actions.doReconnect(timestamp)); - await TestHelper.wait(300); - const actions = testStore.getActions().map((a) => a.type); - expect(actions).toEqual(expectedActions); - expect(actions).not.toEqual(expect.arrayContaining(expectedMissingActions)); - }); -}); - -describe('Actions.Websocket notVisibleUsersActions', () => { - configureMockStore([thunk]); - - const me = TestHelper.fakeUserWithId(); - const user = TestHelper.fakeUserWithId(); - const user2 = TestHelper.fakeUserWithId(); - const user3 = TestHelper.fakeUserWithId(); - const user4 = TestHelper.fakeUserWithId(); - const user5 = TestHelper.fakeUserWithId(); - - it('should do nothing if the known users and the profiles list are the same', async () => { - const profiles = { - [me.id]: me, - [user.id]: user, - [user2.id]: user2, - [user3.id]: user3, - }; - Client4.serverVersion = '5.23.0'; - - const state = { - entities: { - users: { - currentUserId: me.id, - profiles, - }, - }, - }; - - mockGetKnownUsersRequest([user.id, user2.id, user3.id]); - - const actions = await Actions.notVisibleUsersActions(state); - expect(actions.length).toEqual(0); - }); - - it('should do nothing if there are known users in my memberships but not in the profiles list', async () => { - const profiles = { - [me.id]: me, - [user3.id]: user3, - }; - Client4.serverVersion = '5.23.0'; - - const state = { - entities: { - users: { - currentUserId: me.id, - profiles, - }, - }, - }; - - mockGetKnownUsersRequest([user.id, user2.id, user3.id]); - - const actions = await Actions.notVisibleUsersActions(state); - expect(actions.length).toEqual(0); - }); - - it('should remove the users if there are unknown users in the profiles list', async () => { - const profiles = { - [me.id]: me, - [user.id]: user, - [user2.id]: user2, - [user3.id]: user3, - [user4.id]: user4, - [user5.id]: user5, - }; - Client4.serverVersion = '5.23.0'; - - const state = { - entities: { - users: { - currentUserId: me.id, - profiles, - }, - }, - }; - - mockGetKnownUsersRequest([user.id, user3.id]); - - const expectedAction = [ - {type: UserTypes.PROFILE_NO_LONGER_VISIBLE, data: {user_id: user2.id}}, - {type: UserTypes.PROFILE_NO_LONGER_VISIBLE, data: {user_id: user4.id}}, - {type: UserTypes.PROFILE_NO_LONGER_VISIBLE, data: {user_id: user5.id}}, - ]; - const actions = await Actions.notVisibleUsersActions(state); - expect(actions.length).toEqual(3); - expect(actions).toEqual(expectedAction); - }); - - it('should do nothing if the server version is less than 5.23', async () => { - const profiles = { - [me.id]: me, - [user.id]: user, - [user2.id]: user2, - [user3.id]: user3, - [user4.id]: user4, - [user5.id]: user5, - }; - Client4.serverVersion = '5.22.0'; - - const state = { - entities: { - users: { - currentUserId: me.id, - profiles, - }, - }, - }; - - mockGetKnownUsersRequest([user.id, user3.id]); - - const actions = await Actions.notVisibleUsersActions(state); - expect(actions.length).toEqual(0); - }); -}); - -describe('Actions.Websocket handleUserTypingEvent', () => { - const mockStore = configureMockStore([thunk]); - - const currentUserId = 'user-id'; - const otherUserId = 'other-user-id'; - const currentChannelId = 'channel-id'; - const otherChannelId = 'other-channel-id'; - - const initialState = { - entities: { - general: { - config: {}, - }, - channels: { - currentChannelId, - channels: { - currentChannelId: { - id: currentChannelId, - name: 'channel', - }, - }, - }, - users: { - currentUserId, - profiles: { - [currentUserId]: {}, - [otherUserId]: {}, - }, - statuses: { - [currentUserId]: General.ONLINE, - [otherUserId]: General.OFFLINE, - }, - }, - preferences: { - myPreferences: {}, - }, - }, - }; - - it('dispatches actions for current channel if other user is typing', async () => { - const state = {...initialState}; - const testStore = await mockStore(state); - const msg = {broadcast: {channel_id: currentChannelId}, data: {parent_id: 'parent-id', user_id: otherUserId}}; - - nock(Client4.getUsersRoute()). - post('/status/ids', JSON.stringify([otherUserId])). - reply(200, ['away']); - - const expectedActionsTypes = [ - WebsocketEvents.TYPING, - UserTypes.RECEIVED_STATUSES, - ]; - - await testStore.dispatch(Actions.handleUserTypingEvent(msg)); - await TestHelper.wait(300); - const actionTypes = testStore.getActions().map((action) => action.type); - expect(actionTypes).toEqual(expectedActionsTypes); - }); - - it('does not dispatch actions for non current channel', async () => { - const state = {...initialState}; - const testStore = await mockStore(state); - const msg = {broadcast: {channel_id: otherChannelId}, data: {parent_id: 'parent-id', user_id: otherUserId}}; - - const expectedActionsTypes = []; - - await testStore.dispatch(Actions.handleUserTypingEvent(msg)); - const actionTypes = testStore.getActions().map((action) => action.type); - expect(actionTypes).toEqual(expectedActionsTypes); - }); -}); diff --git a/app/actions/websocket.ts b/app/actions/websocket.ts deleted file mode 100644 index 8290dd88f..000000000 --- a/app/actions/websocket.ts +++ /dev/null @@ -1,1240 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Client4} from '@mm-redux/client'; -import websocketClient from '@websocket'; -import {ChannelTypes, GeneralTypes, EmojiTypes, PostTypes, PreferenceTypes, TeamTypes, UserTypes, RoleTypes, IntegrationTypes, GroupTypes} from '@mm-redux/action_types'; -import {General, Preferences} from '@mm-redux/constants'; -import { - getAllChannels, - getChannel, - getChannelsNameMapInTeam, - getCurrentChannelId, - getMyChannelMember as selectMyChannelMember, - getRedirectChannelNameForTeam, - getCurrentChannelStats, - getChannelMembersInChannels, - isManuallyUnread, -} from '@mm-redux/selectors/entities/channels'; -import {getConfig} from '@mm-redux/selectors/entities/general'; -import {getAllPosts, getPost as selectPost} from '@mm-redux/selectors/entities/posts'; -import {getCurrentTeamId, getTeams as getTeamsSelector} from '@mm-redux/selectors/entities/teams'; -import {getCurrentUser, getCurrentUserId, getUsers, getUserStatuses} from '@mm-redux/selectors/entities/users'; -import {getChannelByName, getUserIdFromChannelName} from '@mm-redux/utils/channel_utils'; -import EventEmitter from '@mm-redux/utils/event_emitter'; -import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; -import {isGuest, removeUserFromList} from '@mm-redux/utils/user_utils'; -import {isFromWebhook, isSystemMessage, shouldIgnorePost} from '@mm-redux/utils/post_utils'; - -import {DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions'; - -import {getCustomEmojiForReaction, getUnreadPostData, postDeleted, receivedNewPost, receivedPost} from '@mm-redux/actions/posts'; -import {markChannelAsRead} from '@mm-redux/actions/channels'; -import {getProfilesByIds, getStatusesByIds} from '@mm-redux/actions/users'; -import {Channel, ChannelMembership} from '@mm-redux/types/channels'; -import {PreferenceType} from '@mm-redux/types/preferences'; -import {TeamMembership} from '@mm-redux/types/teams'; -import {Dictionary} from '@mm-redux/types/utilities'; - -import {WebsocketEvents} from '@constants'; -import { - fetchMyChannel, - fetchMyChannelMember, - fetchChannelAndMyMember, - getAddedDmUsersIfNecessary, - makeDirectChannelVisibleIfNecessary, - makeGroupMessageVisibleIfNecessary, - markChannelAsUnread, -} from '@actions/helpers/channels'; -import {loadChannelsForTeam, markAsViewedAndReadBatch} from '@actions/views/channel'; -import {getPost, getPosts, getPostsAdditionalDataBatch, getPostThread} from '@actions/views/post'; -import {getMe, loadMe} from '@actions/views/user'; -import {GlobalState} from '@mm-redux/types/store'; - -export type WebsocketBroadcast = { - omit_users: Dictionary; - user_id: string; - channel_id: string; - team_id: string; -} - -export type WebSocketMessage = { - event: string; - data: any; - broadcast: WebsocketBroadcast; - seq: number; -} - -export function init(additionalOptions: any = {}) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const config = getConfig(getState()); - let connUrl = additionalOptions.websocketUrl || config.WebsocketURL || Client4.getUrl(); - const authToken = Client4.getToken(); - - connUrl += `${Client4.getUrlVersion()}/websocket`; - websocketClient.setFirstConnectCallback(() => dispatch(handleFirstConnect())); - websocketClient.setEventCallback((evt: WebSocketMessage) => dispatch(handleEvent(evt))); - websocketClient.setReconnectCallback(() => dispatch(handleReconnect())); - websocketClient.setCloseCallback((connectFailCount: number) => dispatch(handleClose(connectFailCount))); - - const websocketOpts = { - connectionUrl: connUrl, - ...additionalOptions, - }; - - return websocketClient.initialize(authToken, websocketOpts); - }; -} - -let reconnect = false; -export function close(shouldReconnect = false) { - reconnect = shouldReconnect; - websocketClient.close(true); - - return { - type: GeneralTypes.WEBSOCKET_CLOSED, - timestamp: Date.now(), - data: null, - }; -} - -export function doFirstConnect(now: number) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const {lastDisconnectAt} = state.websocket; - const actions: Array = [{ - type: GeneralTypes.WEBSOCKET_SUCCESS, - timestamp: now, - data: null, - }]; - - if (isMinimumServerVersion(Client4.getServerVersion(), 5, 14) && lastDisconnectAt) { - const currentUserId = getCurrentUserId(state); - const users = getUsers(state); - const userIds = Object.keys(users); - const userUpdates = await Client4.getProfilesByIds(userIds, {since: lastDisconnectAt}); - - if (userUpdates.length) { - removeUserFromList(currentUserId, userUpdates); - actions.push({ - type: UserTypes.RECEIVED_PROFILES_LIST, - data: userUpdates, - }); - } - } - - dispatch(batchActions(actions, 'BATCH_WS_CONNCET')); - - return {data: true}; - }; -} - -export function doReconnect(now: number) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const currentTeamId = getCurrentTeamId(state); - const currentChannelId = getCurrentChannelId(state); - const currentUserId = getCurrentUserId(state); - const users = getUsers(state); - const {lastDisconnectAt} = state.websocket; - const actions: Array = []; - - dispatch({ - type: GeneralTypes.WEBSOCKET_SUCCESS, - timestamp: now, - data: null, - }); - - try { - const {data: me}: any = await dispatch(loadMe(null, null, true)); - - if (!me.error) { - const roles = []; - - if (me.roles?.length) { - roles.push(...me.roles); - } - - actions.push({ - type: PreferenceTypes.RECEIVED_ALL_PREFERENCES, - data: me.preferences, - }, { - type: TeamTypes.RECEIVED_MY_TEAM_UNREADS, - data: me.teamUnreads, - }, { - type: TeamTypes.RECEIVED_TEAMS_LIST, - data: me.teams, - }, { - type: TeamTypes.RECEIVED_MY_TEAM_MEMBERS, - data: me.teamMembers, - }); - - const currentTeamMembership = me.teamMembers.find((tm: TeamMembership) => tm.team_id === currentTeamId && tm.delete_at === 0); - - if (currentTeamMembership) { - const {data: myData}: any = await dispatch(loadChannelsForTeam(currentTeamId, true)); - - if (myData?.channels && myData?.channelMembers) { - actions.push({ - type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS, - data: myData, - }); - - const stillMemberOfCurrentChannel = myData.channelMembers.find((cm: ChannelMembership) => cm.channel_id === currentChannelId); - - const channelStillExists = myData.channels.find((c: Channel) => c.id === currentChannelId); - const config = me.config || getConfig(getState()); - const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; - if (!stillMemberOfCurrentChannel || !channelStillExists || (!viewArchivedChannels && channelStillExists.delete_at !== 0)) { - EventEmitter.emit(General.SWITCH_TO_DEFAULT_CHANNEL, currentTeamId); - } else { - dispatch(getPosts(currentChannelId)); - } - } - - if (myData.roles?.length) { - roles.push(...myData.roles); - } - } else { - // If the user is no longer a member of this team when reconnecting - const newMsg = { - data: { - user_id: currentUserId, - team_id: currentTeamId, - }, - }; - dispatch(handleLeaveTeamEvent(newMsg)); - } - - if (roles.length) { - actions.push({ - type: RoleTypes.RECEIVED_ROLES, - data: roles, - }); - } - - if (isMinimumServerVersion(Client4.getServerVersion(), 5, 14) && lastDisconnectAt) { - const userIds = Object.keys(users); - const userUpdates = await Client4.getProfilesByIds(userIds, {since: lastDisconnectAt}); - - if (userUpdates.length) { - removeUserFromList(currentUserId, userUpdates); - actions.push({ - type: UserTypes.RECEIVED_PROFILES_LIST, - data: userUpdates, - }); - } - } - - if (actions.length) { - dispatch(batchActions(actions, 'BATCH_WS_RECONNECT')); - } - } - } catch (e) { - // do nothing - } - - return {data: true}; - }; -} - -function handleFirstConnect() { - return (dispatch: DispatchFunc) => { - const now = Date.now(); - - if (reconnect) { - reconnect = false; - return dispatch(doReconnect(now)); - } - return dispatch(doFirstConnect(now)); - }; -} - -function handleReconnect() { - return (dispatch: DispatchFunc) => { - return dispatch(doReconnect(Date.now())); - }; -} - -function handleClose(connectFailCount: number) { - return { - type: GeneralTypes.WEBSOCKET_FAILURE, - error: connectFailCount, - data: null, - timestamp: Date.now(), - }; -} - -function handleEvent(msg: WebSocketMessage) { - return (dispatch: DispatchFunc) => { - switch (msg.event) { - case WebsocketEvents.POSTED: - case WebsocketEvents.EPHEMERAL_MESSAGE: - return dispatch(handleNewPostEvent(msg)); - case WebsocketEvents.POST_EDITED: - return dispatch(handlePostEdited(msg)); - case WebsocketEvents.POST_DELETED: - return dispatch(handlePostDeleted(msg)); - case WebsocketEvents.POST_UNREAD: - return dispatch(handlePostUnread(msg)); - case WebsocketEvents.LEAVE_TEAM: - return dispatch(handleLeaveTeamEvent(msg)); - case WebsocketEvents.UPDATE_TEAM: - return dispatch(handleUpdateTeamEvent(msg)); - case WebsocketEvents.ADDED_TO_TEAM: - return dispatch(handleTeamAddedEvent(msg)); - case WebsocketEvents.USER_ADDED: - return dispatch(handleUserAddedEvent(msg)); - case WebsocketEvents.USER_REMOVED: - return dispatch(handleUserRemovedEvent(msg)); - case WebsocketEvents.USER_UPDATED: - return dispatch(handleUserUpdatedEvent(msg)); - case WebsocketEvents.ROLE_ADDED: - return dispatch(handleRoleAddedEvent(msg)); - case WebsocketEvents.ROLE_REMOVED: - return dispatch(handleRoleRemovedEvent(msg)); - case WebsocketEvents.ROLE_UPDATED: - return dispatch(handleRoleUpdatedEvent(msg)); - case WebsocketEvents.USER_ROLE_UPDATED: - return dispatch(handleUserRoleUpdated(msg)); - case WebsocketEvents.MEMBERROLE_UPDATED: - return dispatch(handleUpdateMemberRoleEvent(msg)); - case WebsocketEvents.CHANNEL_CREATED: - return dispatch(handleChannelCreatedEvent(msg)); - case WebsocketEvents.CHANNEL_DELETED: - return dispatch(handleChannelDeletedEvent(msg)); - case WebsocketEvents.CHANNEL_UNARCHIVED: - return dispatch(handleChannelUnarchiveEvent(msg)); - case WebsocketEvents.CHANNEL_UPDATED: - return dispatch(handleChannelUpdatedEvent(msg)); - case WebsocketEvents.CHANNEL_CONVERTED: - return dispatch(handleChannelConvertedEvent(msg)); - case WebsocketEvents.CHANNEL_VIEWED: - return dispatch(handleChannelViewedEvent(msg)); - case WebsocketEvents.CHANNEL_MEMBER_UPDATED: - return dispatch(handleChannelMemberUpdatedEvent(msg)); - case WebsocketEvents.CHANNEL_SCHEME_UPDATED: - return dispatch(handleChannelSchemeUpdatedEvent(msg)); - case WebsocketEvents.DIRECT_ADDED: - return dispatch(handleDirectAddedEvent(msg)); - case WebsocketEvents.PREFERENCE_CHANGED: - return dispatch(handlePreferenceChangedEvent(msg)); - case WebsocketEvents.PREFERENCES_CHANGED: - return dispatch(handlePreferencesChangedEvent(msg)); - case WebsocketEvents.PREFERENCES_DELETED: - return dispatch(handlePreferencesDeletedEvent(msg)); - case WebsocketEvents.STATUS_CHANGED: - return dispatch(handleStatusChangedEvent(msg)); - case WebsocketEvents.TYPING: - return dispatch(handleUserTypingEvent(msg)); - case WebsocketEvents.HELLO: - handleHelloEvent(msg); - break; - case WebsocketEvents.REACTION_ADDED: - return dispatch(handleReactionAddedEvent(msg)); - case WebsocketEvents.REACTION_REMOVED: - return dispatch(handleReactionRemovedEvent(msg)); - case WebsocketEvents.EMOJI_ADDED: - return dispatch(handleAddEmoji(msg)); - case WebsocketEvents.LICENSE_CHANGED: - return dispatch(handleLicenseChangedEvent(msg)); - case WebsocketEvents.CONFIG_CHANGED: - return dispatch(handleConfigChangedEvent(msg)); - case WebsocketEvents.OPEN_DIALOG: - return dispatch(handleOpenDialogEvent(msg)); - case WebsocketEvents.RECEIVED_GROUP: - return dispatch(handleGroupUpdatedEvent(msg)); - } - - return {data: true}; - }; -} - -function handleNewPostEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const currentChannelId = getCurrentChannelId(state); - const currentUserId = getCurrentUserId(state); - const data = JSON.parse(msg.data.post); - const post = { - ...data, - ownPost: data.user_id === currentUserId, - }; - - const actions: Array = []; - - const exists = selectPost(state, post.pending_post_id); - - if (!exists) { - if (getCurrentChannelId(state) === post.channel_id) { - EventEmitter.emit(WebsocketEvents.INCREASE_POST_VISIBILITY_BY_ONE); - } - - const myChannel = getChannel(state, post.channel_id); - if (!myChannel) { - const channel = await fetchMyChannel(post.channel_id); - if (channel.data) { - actions.push({ - type: ChannelTypes.RECEIVED_CHANNEL, - data: channel.data, - }); - } - } - - const myChannelMember = selectMyChannelMember(state, post.channel_id); - if (!myChannelMember) { - const member = await fetchMyChannelMember(post.channel_id); - if (member.data) { - actions.push({ - type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, - data: member.data, - }); - } - } - - actions.push(receivedNewPost(post)); - - // If we don't have the thread for this post, fetch it from the server - // and include the actions in the batch - if (post.root_id) { - const rootPost = selectPost(state, post.root_id); - - if (!rootPost) { - const thread: any = await dispatch(getPostThread(post.root_id, true)); - if (thread.data?.length) { - actions.push(...thread.data); - } - } - } - - if (post.channel_id === currentChannelId) { - const id = post.channel_id + post.root_id; - const {typing} = state.entities; - - if (typing[id]) { - actions.push({ - type: WebsocketEvents.STOP_TYPING, - data: { - id, - userId: post.user_id, - now: Date.now(), - }, - }); - } - } - - // Fetch and batch additional post data - const additional: any = await dispatch(getPostsAdditionalDataBatch([post])); - if (additional.data.length) { - actions.push(...additional.data); - } - - if (msg.data.channel_type === General.DM_CHANNEL) { - const otherUserId = getUserIdFromChannelName(currentUserId, msg.data.channel_name); - const dmAction = makeDirectChannelVisibleIfNecessary(state, otherUserId); - if (dmAction) { - actions.push(dmAction); - } - } else if (msg.data.channel_type === General.GM_CHANNEL) { - const gmActions = await makeGroupMessageVisibleIfNecessary(state, post.channel_id); - if (gmActions) { - actions.push(...gmActions); - } - } - - if (!shouldIgnorePost(post)) { - let markAsRead = false; - let markAsReadOnServer = false; - - if (!isManuallyUnread(state, post.channel_id)) { - if ( - post.user_id === getCurrentUserId(state) && - !isSystemMessage(post) && - !isFromWebhook(post) - ) { - markAsRead = true; - markAsReadOnServer = false; - } else if (post.channel_id === currentChannelId) { - markAsRead = true; - markAsReadOnServer = true; - } - } - - if (markAsRead) { - const readActions = markAsViewedAndReadBatch(state, post.channel_id, undefined, markAsReadOnServer); - actions.push(...readActions); - } else { - const unreadActions = markChannelAsUnread(state, msg.data.team_id, post.channel_id, msg.data.mentions); - actions.push(...unreadActions); - } - } - - dispatch(batchActions(actions, 'BATCH_WS_NEW_POST')); - } - - return {data: true}; - }; -} - -function handlePostEdited(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const currentUserId = getCurrentUserId(state); - const data = JSON.parse(msg.data.post); - const post = { - ...data, - ownPost: data.user_id === currentUserId, - }; - const actions = [receivedPost(post)]; - - const additional: any = await dispatch(getPostsAdditionalDataBatch([post])); - if (additional.data.length) { - actions.push(...additional.data); - } - - dispatch(batchActions(actions, 'BATCH_WS_POST_EDITED')); - return {data: true}; - }; -} - -function handlePostDeleted(msg: WebSocketMessage) { - const data = JSON.parse(msg.data.post); - - return postDeleted(data); -} - -function handlePostUnread(msg: WebSocketMessage) { - return (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const manual = isManuallyUnread(state, msg.broadcast.channel_id); - - if (!manual) { - const member = selectMyChannelMember(state, msg.broadcast.channel_id); - const delta = member ? member.msg_count - msg.data.msg_count : msg.data.msg_count; - const info = { - ...msg.data, - user_id: msg.broadcast.user_id, - team_id: msg.broadcast.team_id, - channel_id: msg.broadcast.channel_id, - deltaMsgs: delta, - }; - const data = getUnreadPostData(info, state); - dispatch({ - type: ChannelTypes.POST_UNREAD_SUCCESS, - data, - }); - return {data}; - } - - return {data: null}; - }; -} - -function handleLeaveTeamEvent(msg: Partial) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const teams = getTeamsSelector(state); - const currentTeamId = getCurrentTeamId(state); - const currentUser = getCurrentUser(state); - - if (currentUser.id === msg.data.user_id) { - const actions: Array = [{type: TeamTypes.LEAVE_TEAM, data: teams[msg.data.team_id]}]; - if (isGuest(currentUser.roles)) { - const notVisible = await notVisibleUsersActions(state); - if (notVisible.length) { - actions.push(...notVisible); - } - } - dispatch(batchActions(actions, 'BATCH_WS_LEAVE_TEAM')); - - // if they are on the team being removed deselect the current team and channel - if (currentTeamId === msg.data.team_id) { - EventEmitter.emit('leave_team'); - } - } - return {data: true}; - }; -} - -function handleUpdateTeamEvent(msg: WebSocketMessage) { - return { - type: TeamTypes.UPDATED_TEAM, - data: JSON.parse(msg.data.team), - }; -} - -function handleTeamAddedEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc) => { - try { - const [team, teamUnreads] = await Promise.all([ - Client4.getTeam(msg.data.team_id), - Client4.getMyTeamUnreads(), - ]); - - const actions = [{ - type: TeamTypes.RECEIVED_TEAM, - data: team, - }, { - type: TeamTypes.RECEIVED_MY_TEAM_UNREADS, - data: teamUnreads, - }]; - - dispatch(batchActions(actions, 'BATCH_WS_TEAM_ADDED')); - } catch { - // do nothing - } - - return {data: true}; - }; -} - -function handleUserRoleUpdated(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc) => { - try { - const roles = msg.data.roles.split(' '); - const data = await Client4.getRolesByNames(roles); - - dispatch({ - type: RoleTypes.RECEIVED_ROLES, - data: data.roles, - }); - } catch { - // do nothing - } - - return {data: true}; - }; -} - -function handleUserAddedEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - try { - const state = getState(); - const currentChannelId = getCurrentChannelId(state); - const currentTeamId = getCurrentTeamId(state); - const currentUserId = getCurrentUserId(state); - const teamId = msg.data.team_id; - const actions: Array = [{ - type: ChannelTypes.CHANNEL_MEMBER_ADDED, - data: { - channel_id: msg.broadcast.channel_id, - user_id: msg.data.user_id, - }, - }]; - - if (msg.broadcast.channel_id === currentChannelId) { - const stat = await Client4.getChannelStats(currentChannelId); - actions.push({ - type: ChannelTypes.RECEIVED_CHANNEL_STATS, - data: stat, - }); - } - - if (teamId === currentTeamId && msg.data.user_id === currentUserId) { - const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); - - if (channelActions.length) { - actions.push(...channelActions); - } - } - - dispatch(batchActions(actions, 'BATCH_WS_USER_ADDED')); - } catch (error) { - //do nothing - } - return {data: true}; - }; -} - -function handleUserRemovedEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - try { - const state = getState(); - const channels = getAllChannels(state); - const currentChannelId = getCurrentChannelId(state); - const currentTeamId = getCurrentTeamId(state); - const currentUser = getCurrentUser(state); - const actions: Array = []; - let channelId; - let userId; - - if (msg.data.user_id) { - userId = msg.data.user_id; - channelId = msg.broadcast.channel_id; - } else if (msg.broadcast.user_id) { - channelId = msg.data.channel_id; - userId = msg.broadcast.user_id; - } - - if (userId) { - actions.push({ - type: ChannelTypes.CHANNEL_MEMBER_REMOVED, - data: { - channel_id: channelId, - user_id: userId, - }, - }); - } - - const channel = channels[currentChannelId]; - - if (msg.data?.user_id !== currentUser.id) { - const members = getChannelMembersInChannels(state); - const isMember = Object.values(members).some((member) => member[msg.data.user_id]); - if (channel && isGuest(currentUser.roles) && !isMember) { - actions.push({ - type: UserTypes.PROFILE_NO_LONGER_VISIBLE, - data: {user_id: msg.data.user_id}, - }, { - type: TeamTypes.REMOVE_MEMBER_FROM_TEAM, - data: {team_id: channel.team_id, user_id: msg.data.user_id}, - }); - } - } - - let redirectToDefaultChannel = false; - if (msg.broadcast.user_id === currentUser.id && currentTeamId) { - const {data: myData}: any = await dispatch(loadChannelsForTeam(currentTeamId, true)); - - if (myData?.channels && myData?.channelMembers) { - actions.push({ - type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS, - data: myData, - }); - } - - if (channel) { - actions.push({ - type: ChannelTypes.LEAVE_CHANNEL, - data: { - id: msg.data.channel_id, - user_id: currentUser.id, - team_id: channel.team_id, - type: channel.type, - }, - }); - } - - if (msg.data.channel_id === currentChannelId) { - // emit the event so the client can change his own state - redirectToDefaultChannel = true; - } - if (isGuest(currentUser.roles)) { - const notVisible = await notVisibleUsersActions(state); - if (notVisible.length) { - actions.push(...notVisible); - } - } - } else if (msg.data.channel_id === currentChannelId) { - const stat = await Client4.getChannelStats(currentChannelId); - actions.push({ - type: ChannelTypes.RECEIVED_CHANNEL_STATS, - data: stat, - }); - } - - dispatch(batchActions(actions, 'BATCH_WS_USER_REMOVED')); - if (redirectToDefaultChannel) { - EventEmitter.emit(General.SWITCH_TO_DEFAULT_CHANNEL, currentTeamId); - } - } catch { - // do nothing - } - - return {data: true}; - }; -} - -function handleUserUpdatedEvent(msg: WebSocketMessage) { - return (dispatch: DispatchFunc, getState: GetStateFunc) => { - const currentUser = getCurrentUser(getState()); - const user = msg.data.user; - - if (user.id === currentUser.id) { - if (user.update_at > currentUser.update_at) { - // Need to request me to make sure we don't override with sanitized fields from the - // websocket event - dispatch(getMe()); - } - } else { - dispatch({ - type: UserTypes.RECEIVED_PROFILES, - data: { - [user.id]: user, - }, - }); - } - return {data: true}; - }; -} - -function handleRoleAddedEvent(msg: WebSocketMessage) { - const role = JSON.parse(msg.data.role); - - return { - type: RoleTypes.RECEIVED_ROLE, - data: role, - }; -} - -function handleRoleRemovedEvent(msg: WebSocketMessage) { - const role = JSON.parse(msg.data.role); - - return { - type: RoleTypes.ROLE_DELETED, - data: role, - }; -} - -function handleRoleUpdatedEvent(msg: WebSocketMessage) { - const role = JSON.parse(msg.data.role); - - return { - type: RoleTypes.RECEIVED_ROLE, - data: role, - }; -} - -function handleChannelCreatedEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const {channel_id: channelId, team_id: teamId} = msg.data; - const state = getState(); - const channels = getAllChannels(state); - const currentTeamId = getCurrentTeamId(state); - - if (teamId === currentTeamId && !channels[channelId]) { - const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); - if (channelActions.length) { - dispatch(batchActions(channelActions, 'BATCH_WS_CHANNEL_CREATED')); - } - } - return {data: true}; - }; -} - -function handleChannelDeletedEvent(msg: WebSocketMessage) { - return (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const currentChannelId = getCurrentChannelId(state); - const currentTeamId = getCurrentTeamId(state); - const config = getConfig(state); - const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; - const actions: Array = [{ - type: ChannelTypes.RECEIVED_CHANNEL_DELETED, - data: { - id: msg.data.channel_id, - deleteAt: msg.data.delete_at, - team_id: msg.broadcast.team_id, - viewArchivedChannels, - }, - }]; - - if (msg.broadcast.team_id === currentTeamId) { - if (msg.data.channel_id === currentChannelId && !viewArchivedChannels) { - const channelsInTeam = getChannelsNameMapInTeam(state, currentTeamId); - const channel = getChannelByName(channelsInTeam, getRedirectChannelNameForTeam(state, currentTeamId)); - if (channel && channel.id) { - actions.push({type: ChannelTypes.SELECT_CHANNEL, data: channel.id}); - } - EventEmitter.emit(General.DEFAULT_CHANNEL, ''); - } - } - - dispatch(batchActions(actions, 'BATCH_WS_CHANNEL_ARCHIVED')); - - return {data: true}; - }; -} - -function handleChannelUnarchiveEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const currentTeamId = getCurrentTeamId(state); - const config = getConfig(state); - const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; - - if (msg.broadcast.team_id === currentTeamId) { - const actions: Array = [{ - type: ChannelTypes.RECEIVED_CHANNEL_UNARCHIVED, - data: { - id: msg.data.channel_id, - team_id: msg.data.team_id, - deleteAt: 0, - viewArchivedChannels, - }, - }]; - - const {data: myData}: any = await dispatch(loadChannelsForTeam(currentTeamId, true)); - if (myData?.channels && myData?.channelMembers) { - actions.push({ - type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS, - data: myData, - }); - } - - dispatch(batchActions(actions, 'BATCH_WS_CHANNEL_UNARCHIVED')); - } - return {data: true}; - }; -} - -function handleChannelUpdatedEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - let channel; - try { - channel = msg.data ? JSON.parse(msg.data.channel) : null; - } catch (err) { - return {error: err}; - } - - const currentChannelId = getCurrentChannelId(getState()); - if (channel) { - dispatch({ - type: ChannelTypes.RECEIVED_CHANNEL, - data: channel, - }); - - if (currentChannelId === channel.id) { - // Emit an event with the channel received as we need to handle - // the changes without listening to the store - EventEmitter.emit(WebsocketEvents.CHANNEL_UPDATED, channel); - } - } - return {data: true}; - }; -} - -// handleChannelConvertedEvent handles updating of channel which is converted from public to private - -function handleChannelConvertedEvent(msg: WebSocketMessage) { - return (dispatch: DispatchFunc, getState: GetStateFunc) => { - const channelId = msg.data.channel_id; - if (channelId) { - const channel = getChannel(getState(), channelId); - if (channel) { - dispatch({ - type: ChannelTypes.RECEIVED_CHANNEL, - data: {...channel, type: General.PRIVATE_CHANNEL}, - }); - } - } - return {data: true}; - }; -} - -function handleChannelViewedEvent(msg: WebSocketMessage) { - return (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const {channel_id: channelId} = msg.data; - const currentChannelId = getCurrentChannelId(state); - const currentUserId = getCurrentUserId(state); - - if (channelId !== currentChannelId && currentUserId === msg.broadcast.user_id) { - dispatch(markChannelAsRead(channelId, undefined, false)); - } - return {data: true}; - }; -} - -function handleChannelMemberUpdatedEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc) => { - try { - const channelMember = JSON.parse(msg.data.channelMember); - const rolesToLoad = channelMember.roles.split(' '); - const actions: Array = [{ - type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, - data: channelMember, - }]; - - const roles = await Client4.getRolesByNames(rolesToLoad); - if (roles.length) { - actions.push({ - type: RoleTypes.RECEIVED_ROLES, - data: roles, - }); - } - - dispatch(batchActions(actions, 'BATCH_WS_CHANNEL_MEMBER_UPDATE')); - } catch { - //do nothing - } - return {data: true}; - }; -} - -function handleChannelSchemeUpdatedEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc) => { - const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); - if (channelActions.length) { - dispatch(batchActions(channelActions, 'BATCH_WS_SCHEME_UPDATE')); - } - return {data: true}; - }; -} - -function handleUpdateMemberRoleEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc) => { - const memberData = JSON.parse(msg.data.member); - const roles = memberData.roles.split(' '); - const actions = []; - - try { - const newRoles = await Client4.getRolesByNames(roles); - if (newRoles.length) { - actions.push({ - type: RoleTypes.RECEIVED_ROLES, - data: newRoles, - }); - } - } catch (error) { - return {error}; - } - - actions.push({ - type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, - data: memberData, - }); - - dispatch(batchActions(actions)); - return {data: true}; - }; -} - -function handleDirectAddedEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc) => { - const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); - if (channelActions.length) { - dispatch(batchActions(channelActions, 'BATCH_WS_DM_ADDED')); - } - return {data: true}; - }; -} - -function handlePreferenceChangedEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const preference = JSON.parse(msg.data.preference); - const actions: Array = [{ - type: PreferenceTypes.RECEIVED_PREFERENCES, - data: [preference], - }]; - - const dmActions = await getAddedDmUsersIfNecessary(getState(), [preference]); - if (dmActions.length) { - actions.push(...dmActions); - } - - dispatch(batchActions(actions, 'BATCH_WS_PREFERENCE_CHANGED')); - return {data: true}; - }; -} - -function handlePreferencesChangedEvent(msg: WebSocketMessage) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const preferences: PreferenceType[] = JSON.parse(msg.data.preferences); - const posts = getAllPosts(getState()); - const actions: Array = [{ - type: PreferenceTypes.RECEIVED_PREFERENCES, - data: preferences, - }]; - - preferences.forEach((pref) => { - if (pref.category === Preferences.CATEGORY_FLAGGED_POST && !posts[pref.name]) { - dispatch(getPost(pref.name)); - } - }); - - const dmActions = await getAddedDmUsersIfNecessary(getState(), preferences); - if (dmActions.length) { - actions.push(...dmActions); - } - - dispatch(batchActions(actions, 'BATCH_WS_PREFERENCES_CHANGED')); - return {data: true}; - }; -} - -function handlePreferencesDeletedEvent(msg: WebSocketMessage) { - const preferences = JSON.parse(msg.data.preferences); - - return {type: PreferenceTypes.DELETED_PREFERENCES, data: preferences}; -} - -function handleStatusChangedEvent(msg: WebSocketMessage) { - return { - type: UserTypes.RECEIVED_STATUSES, - data: [{user_id: msg.data.user_id, status: msg.data.status}], - }; -} - -function handleHelloEvent(msg: WebSocketMessage) { - const serverVersion = msg.data.server_version; - if (serverVersion && Client4.serverVersion !== serverVersion) { - Client4.serverVersion = serverVersion; - EventEmitter.emit(General.SERVER_VERSION_CHANGED, serverVersion); - } -} - -export function handleUserTypingEvent(msg: WebSocketMessage) { - return (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const currentChannelId = getCurrentChannelId(state); - - if (currentChannelId === msg.broadcast.channel_id) { - const profiles = getUsers(state); - const statuses = getUserStatuses(state); - const currentUserId = getCurrentUserId(state); - const config = getConfig(state); - const userId = msg.data.user_id; - - const data = { - id: msg.broadcast.channel_id + msg.data.parent_id, - userId, - now: Date.now(), - }; - - dispatch({ - type: WebsocketEvents.TYPING, - data, - }); - - setTimeout(() => { - const newState = getState(); - const {typing} = newState.entities; - - if (typing && typing[data.id]) { - dispatch({ - type: WebsocketEvents.STOP_TYPING, - data, - }); - } - }, parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds!, 10)); - - if (!profiles[userId] && userId !== currentUserId) { - dispatch(getProfilesByIds([userId])); - } - - const status = statuses[userId]; - if (status !== General.ONLINE) { - dispatch(getStatusesByIds([userId])); - } - } - - return {data: true}; - }; -} - -function handleReactionAddedEvent(msg: WebSocketMessage) { - return (dispatch: DispatchFunc) => { - const {data} = msg; - const reaction = JSON.parse(data.reaction); - - dispatch(getCustomEmojiForReaction(reaction.emoji_name)); - - dispatch({ - type: PostTypes.RECEIVED_REACTION, - data: reaction, - }); - return {data: true}; - }; -} - -function handleReactionRemovedEvent(msg: WebSocketMessage) { - const {data} = msg; - const reaction = JSON.parse(data.reaction); - - return { - type: PostTypes.REACTION_DELETED, - data: reaction, - }; -} - -function handleAddEmoji(msg: WebSocketMessage) { - const data = JSON.parse(msg.data.emoji); - - return { - type: EmojiTypes.RECEIVED_CUSTOM_EMOJI, - data, - }; -} - -function handleLicenseChangedEvent(msg: WebSocketMessage) { - const data = msg.data.license; - - return { - type: GeneralTypes.CLIENT_LICENSE_RECEIVED, - data, - }; -} - -function handleConfigChangedEvent(msg: WebSocketMessage) { - const data = msg.data.config; - - EventEmitter.emit(General.CONFIG_CHANGED, data); - return { - type: GeneralTypes.CLIENT_CONFIG_RECEIVED, - data, - }; -} - -function handleOpenDialogEvent(msg: WebSocketMessage) { - return (dispatch: DispatchFunc) => { - const data = (msg.data && msg.data.dialog) || {}; - dispatch({type: IntegrationTypes.RECEIVED_DIALOG, data: JSON.parse(data)}); - return {data: true}; - }; -} - -function handleGroupUpdatedEvent(msg: WebSocketMessage) { - return (dispatch: DispatchFunc) => { - const data = JSON.parse(msg.data.group); - dispatch(batchActions([ - { - type: GroupTypes.RECEIVED_GROUP, - data, - }, - { - type: GroupTypes.RECEIVED_MY_GROUPS, - data: [data], - }, - ])); - return {data: true}; - }; -} - -// Helpers -export async function notVisibleUsersActions(state: GlobalState): Promise> { - if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 23)) { - return []; - } - let knownUsers: Set; - try { - const fetchResult = await Client4.getKnownUsers(); - knownUsers = new Set(fetchResult); - } catch (err) { - return []; - } - knownUsers.add(getCurrentUserId(state)); - const allUsers = Object.keys(getUsers(state)); - const usersToRemove = new Set(allUsers.filter((x) => !knownUsers.has(x))); - - const actions = []; - for (const userToRemove of usersToRemove.values()) { - actions.push({type: UserTypes.PROFILE_NO_LONGER_VISIBLE, data: {user_id: userToRemove}}); - } - - return actions; -} - -let lastTimeTypingSent = 0; -export function userTyping(state: GlobalState, channelId: string, parentPostId: string) { - const config = getConfig(state); - const t = Date.now(); - const stats = getCurrentChannelStats(state); - const membersInChannel = stats ? stats.member_count : 0; - - if (((t - lastTimeTypingSent) > parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds!, 10)) && - (membersInChannel < parseInt(config.MaxNotificationsPerChannel!, 10)) && (config.EnableUserTypingMessages === 'true')) { - websocketClient.userTyping(channelId, parentPostId); - lastTimeTypingSent = t; - } -} diff --git a/app/actions/websocket/channels.test.js b/app/actions/websocket/channels.test.js new file mode 100644 index 000000000..8c741c1f3 --- /dev/null +++ b/app/actions/websocket/channels.test.js @@ -0,0 +1,179 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-import-assign */ + +import assert from 'assert'; +import nock from 'nock'; +import {Server, WebSocket as MockWebSocket} from 'mock-socket'; +import thunk from 'redux-thunk'; +import configureMockStore from 'redux-mock-store'; + +import {ChannelTypes, RoleTypes} from '@mm-redux/action_types'; +import * as ChannelActions from '@mm-redux/actions/channels'; +import * as TeamActions from '@mm-redux/actions/teams'; +import {Client4} from '@mm-redux/client'; +import {General} from '@mm-redux/constants'; + +import * as Actions from '@actions/websocket'; +import {WebsocketEvents} from '@constants'; +import globalInitialState from '@store/initial_state'; + +import TestHelper from 'test/test_helper'; +import configureStore from 'test/test_store'; + +global.WebSocket = MockWebSocket; + +describe('Websocket Chanel Events', () => { + let store; + let mockServer; + beforeAll(async () => { + store = await configureStore(); + await TestHelper.initBasic(Client4); + + const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:'); + mockServer = new Server(connUrl); + return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + }); + + afterAll(async () => { + Actions.close()(); + mockServer.stop(); + await TestHelper.tearDown(); + }); + + it('Websocket Handle Channel Member Updated', async () => { + const channelMember = TestHelper.basicChannelMember; + const mockStore = configureMockStore([thunk]); + const st = mockStore(globalInitialState); + await st.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + channelMember.roles = 'channel_user channel_admin'; + const rolesToLoad = channelMember.roles.split(' '); + + nock(Client4.getRolesRoute()). + post('/names', JSON.stringify(rolesToLoad)). + reply(200, rolesToLoad); + + mockServer.emit('message', JSON.stringify({ + event: WebsocketEvents.CHANNEL_MEMBER_UPDATED, + data: { + channelMember: JSON.stringify(channelMember), + }, + })); + + await TestHelper.wait(300); + const storeActions = st.getActions(); + const batch = storeActions.find((a) => a.type === 'BATCH_WS_CHANNEL_MEMBER_UPDATE'); + expect(batch).not.toBeNull(); + const memberAction = batch.payload.find((a) => a.type === ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER); + expect(memberAction).not.toBeNull(); + const rolesActions = batch.payload.find((a) => a.type === RoleTypes.RECEIVED_ROLES); + expect(rolesActions).not.toBeNull(); + expect(rolesActions.data).toEqual(rolesToLoad); + }); + + it('Websocket Handle Channel Created', async () => { + const channelId = TestHelper.basicChannel.id; + const channel = {id: channelId, display_name: 'test', name: TestHelper.basicChannel.name}; + await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: channel}); + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CHANNEL_CREATED, data: {channel_id: channelId, team_id: TestHelper.basicTeam.id}, broadcast: {omit_users: null, user_id: 't36kso9nwtdhbm8dbkd6g4eeby', channel_id: '', team_id: ''}, seq: 57})); + + await TestHelper.wait(300); + + const state = store.getState(); + const entities = state.entities; + const {channels} = entities.channels; + + assert.ok(channels[channel.id]); + }); + + it('Websocket Handle Channel Updated', async () => { + const channelName = 'Test name'; + const channelId = TestHelper.basicChannel.id; + + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CHANNEL_UPDATED, data: {channel: `{"id":"${channelId}","create_at":1508253647983,"update_at":1508254198797,"delete_at":0,"team_id":"55pfercbm7bsmd11p5cjpgsbwr","type":"O","display_name":"${channelName}","name":"${TestHelper.basicChannel.name}","header":"header","purpose":"","last_post_at":1508253648004,"total_msg_count":0,"extra_update_at":1508253648001,"creator_id":"${TestHelper.basicUser.id}"}`}, broadcast: {omit_users: null, user_id: '', channel_id: channelId, team_id: ''}, seq: 62})); + + await TestHelper.wait(300); + + const state = store.getState(); + const entities = state.entities; + const {channels} = entities.channels; + + assert.strictEqual(channels[channelId].display_name, channelName); + }); + + it('Websocket Handle Channel Deleted', async () => { + const time = Date.now(); + await store.dispatch(TeamActions.selectTeam(TestHelper.basicTeam)); + await store.dispatch(ChannelActions.selectChannel(TestHelper.basicChannel.id)); + + store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: {id: TestHelper.generateId(), name: General.DEFAULT_CHANNEL, team_id: TestHelper.basicTeam.id, display_name: General.DEFAULT_CHANNEL}}); + store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: TestHelper.basicChannel}); + + nock(Client4.getUserRoute('me')). + get(`/teams/${TestHelper.basicTeam.id}/channels/members`). + reply(201, [{user_id: TestHelper.basicUser.id, channel_id: TestHelper.basicChannel.id}]); + + mockServer.emit('message', JSON.stringify({ + event: WebsocketEvents.CHANNEL_DELETED, + data: { + channel_id: TestHelper.basicChannel.id, + delete_at: time, + }, + broadcast: { + omit_users: null, + user_id: '', + channel_id: '', + team_id: TestHelper.basicTeam.id, + }, + seq: 68, + })); + + await TestHelper.wait(300); + + const state = store.getState(); + const entities = state.entities; + const {channels, currentChannelId} = entities.channels; + + assert.ok(channels[currentChannelId].name === General.DEFAULT_CHANNEL); + }); + + it('Websocket Handle Channel Unarchive', async () => { + await store.dispatch(TeamActions.selectTeam(TestHelper.basicTeam)); + await store.dispatch(ChannelActions.selectChannel(TestHelper.basicChannel.id)); + + store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: {id: TestHelper.generateId(), name: General.DEFAULT_CHANNEL, team_id: TestHelper.basicTeam.id, display_name: General.DEFAULT_CHANNEL}}); + store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: TestHelper.basicChannel}); + + nock(Client4.getUserRoute('me')). + get(`/teams/${TestHelper.basicTeam.id}/channels/members`). + reply(201, [{user_id: TestHelper.basicUser.id, channel_id: TestHelper.basicChannel.id}]); + + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CHANNEL_UNARCHIVE, data: {channel_id: TestHelper.basicChannel.id}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: TestHelper.basicTeam.id}, seq: 68})); + + await TestHelper.wait(300); + + const state = store.getState(); + const entities = state.entities; + const {channels, currentChannelId} = entities.channels; + + assert.ok(channels[currentChannelId].delete_at === 0); + }); + + it('Websocket Handle Direct Channel', async () => { + const channel = {id: TestHelper.generateId(), name: TestHelper.basicUser.id + '__' + TestHelper.generateId(), type: 'D'}; + + nock(Client4.getChannelsRoute()). + get(`/${channel.id}/members/me`). + reply(201, {user_id: TestHelper.basicUser.id, channel_id: channel.id}); + + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.DIRECT_ADDED, data: {teammate_id: 'btaxe5msnpnqurayosn5p8twuw'}, broadcast: {omit_users: null, user_id: '', channel_id: channel.id, team_id: ''}, seq: 2})); + store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: channel}); + + await TestHelper.wait(300); + + const {channels} = store.getState().entities.channels; + assert.ok(Object.keys(channels).length); + }); +}); \ No newline at end of file diff --git a/app/actions/websocket/channels.ts b/app/actions/websocket/channels.ts new file mode 100644 index 000000000..b77f2ebaa --- /dev/null +++ b/app/actions/websocket/channels.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchChannelAndMyMember} from '@actions/helpers/channels'; +import {loadChannelsForTeam} from '@actions/views/channel'; +import {WebsocketEvents} from '@constants'; +import {markChannelAsRead} from '@mm-redux/actions/channels'; +import {Client4} from '@mm-redux/client'; +import {ChannelTypes, TeamTypes, RoleTypes} from '@mm-redux/action_types'; +import {General} from '@mm-redux/constants'; +import { + getAllChannels, + getChannel, + getChannelsNameMapInTeam, + getCurrentChannelId, + getRedirectChannelNameForTeam, +} from '@mm-redux/selectors/entities/channels'; +import {getConfig} from '@mm-redux/selectors/entities/general'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; +import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; +import {getChannelByName} from '@mm-redux/utils/channel_utils'; +import EventEmitter from '@mm-redux/utils/event_emitter'; + +export function handleChannelConvertedEvent(msg: WebSocketMessage) { + return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => { + const channelId = msg.data.channel_id; + if (channelId) { + const channel = getChannel(getState(), channelId); + if (channel) { + dispatch({ + type: ChannelTypes.RECEIVED_CHANNEL, + data: {...channel, type: General.PRIVATE_CHANNEL}, + }); + } + } + return {data: true}; + }; +} + +export function handleChannelCreatedEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const {channel_id: channelId, team_id: teamId} = msg.data; + const state = getState(); + const channels = getAllChannels(state); + const currentTeamId = getCurrentTeamId(state); + + if (teamId === currentTeamId && !channels[channelId]) { + const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); + if (channelActions.length) { + dispatch(batchActions(channelActions, 'BATCH_WS_CHANNEL_CREATED')); + } + } + return {data: true}; + }; +} + +export function handleChannelDeletedEvent(msg: WebSocketMessage) { + return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => { + const state = getState(); + const currentChannelId = getCurrentChannelId(state); + const currentTeamId = getCurrentTeamId(state); + const config = getConfig(state); + const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; + const actions: Array = [{ + type: ChannelTypes.RECEIVED_CHANNEL_DELETED, + data: { + id: msg.data.channel_id, + deleteAt: msg.data.delete_at, + team_id: msg.broadcast.team_id, + viewArchivedChannels, + }, + }]; + + if (msg.broadcast.team_id === currentTeamId) { + if (msg.data.channel_id === currentChannelId && !viewArchivedChannels) { + const channelsInTeam = getChannelsNameMapInTeam(state, currentTeamId); + const channel = getChannelByName(channelsInTeam, getRedirectChannelNameForTeam(state, currentTeamId)); + if (channel && channel.id) { + actions.push({type: ChannelTypes.SELECT_CHANNEL, data: channel.id}); + } + EventEmitter.emit(General.DEFAULT_CHANNEL, ''); + } + } + + dispatch(batchActions(actions, 'BATCH_WS_CHANNEL_ARCHIVED')); + + return {data: true}; + }; +} + +export function handleChannelMemberUpdatedEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc): Promise => { + try { + const channelMember = JSON.parse(msg.data.channelMember); + const rolesToLoad = channelMember.roles.split(' '); + const actions: Array = [{ + type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, + data: channelMember, + }]; + + const roles = await Client4.getRolesByNames(rolesToLoad); + if (roles.length) { + actions.push({ + type: RoleTypes.RECEIVED_ROLES, + data: roles, + }); + } + + dispatch(batchActions(actions, 'BATCH_WS_CHANNEL_MEMBER_UPDATE')); + } catch { + //do nothing + } + return {data: true}; + }; +} + +export function handleChannelSchemeUpdatedEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc): Promise => { + const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); + if (channelActions.length) { + dispatch(batchActions(channelActions, 'BATCH_WS_SCHEME_UPDATE')); + } + return {data: true}; + }; +} + +export function handleChannelUnarchiveEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const state = getState(); + const currentTeamId = getCurrentTeamId(state); + const config = getConfig(state); + const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; + + if (msg.broadcast.team_id === currentTeamId) { + const actions: Array = [{ + type: ChannelTypes.RECEIVED_CHANNEL_UNARCHIVED, + data: { + id: msg.data.channel_id, + team_id: msg.data.team_id, + deleteAt: 0, + viewArchivedChannels, + }, + }]; + + const {data: myData}: any = await dispatch(loadChannelsForTeam(currentTeamId, true)); + if (myData?.channels && myData?.channelMembers) { + actions.push({ + type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS, + data: myData, + }); + } + + dispatch(batchActions(actions, 'BATCH_WS_CHANNEL_UNARCHIVED')); + } + return {data: true}; + }; +} + +export function handleChannelUpdatedEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + let channel; + try { + channel = msg.data ? JSON.parse(msg.data.channel) : null; + } catch (err) { + return {error: err}; + } + + const currentChannelId = getCurrentChannelId(getState()); + if (channel) { + dispatch({ + type: ChannelTypes.RECEIVED_CHANNEL, + data: channel, + }); + + if (currentChannelId === channel.id) { + // Emit an event with the channel received as we need to handle + // the changes without listening to the store + EventEmitter.emit(WebsocketEvents.CHANNEL_UPDATED, channel); + } + } + return {data: true}; + }; +} + +export function handleChannelViewedEvent(msg: WebSocketMessage) { + return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => { + const state = getState(); + const {channel_id: channelId} = msg.data; + const currentChannelId = getCurrentChannelId(state); + const currentUserId = getCurrentUserId(state); + + if (channelId !== currentChannelId && currentUserId === msg.broadcast.user_id) { + dispatch(markChannelAsRead(channelId, undefined, false)); + } + return {data: true}; + }; +} + +export function handleDirectAddedEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc): Promise => { + const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); + if (channelActions.length) { + dispatch(batchActions(channelActions, 'BATCH_WS_DM_ADDED')); + } + return {data: true}; + }; +} + +export function handleUpdateMemberRoleEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc): Promise => { + const memberData = JSON.parse(msg.data.member); + const roles = memberData.roles.split(' '); + const actions = []; + + try { + const newRoles = await Client4.getRolesByNames(roles); + if (newRoles.length) { + actions.push({ + type: RoleTypes.RECEIVED_ROLES, + data: newRoles, + }); + } + } catch (error) { + return {error}; + } + + actions.push({ + type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, + data: memberData, + }); + + dispatch(batchActions(actions)); + return {data: true}; + }; +} + diff --git a/app/actions/websocket/general.test.js b/app/actions/websocket/general.test.js new file mode 100644 index 000000000..689ba1e1a --- /dev/null +++ b/app/actions/websocket/general.test.js @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-import-assign */ + +import assert from 'assert'; +import {Server, WebSocket as MockWebSocket} from 'mock-socket'; +import {Client4} from '@mm-redux/client'; + +import * as Actions from '@actions/websocket'; +import {WebsocketEvents} from '@constants'; + +import TestHelper from 'test/test_helper'; +import configureStore from 'test/test_store'; + +global.WebSocket = MockWebSocket; + +describe('Websocket General Events', () => { + let store; + let mockServer; + beforeAll(async () => { + store = await configureStore(); + await TestHelper.initBasic(Client4); + + const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:'); + mockServer = new Server(connUrl); + return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + }); + + afterAll(async () => { + Actions.close()(); + mockServer.stop(); + await TestHelper.tearDown(); + }); + + it('handle license changed', async () => { + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.LICENSE_CHANGED, data: {license: {IsLicensed: 'true'}}})); + + await TestHelper.wait(200); + + const state = store.getState(); + + const license = state.entities.general.license; + assert.ok(license); + assert.ok(license.IsLicensed); + }); + + it('handle config changed', async () => { + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CONFIG_CHANGED, data: {config: {EnableCustomEmoji: 'true', EnableLinkPreviews: 'false'}}})); + + await TestHelper.wait(200); + + const state = store.getState(); + + const config = state.entities.general.config; + assert.ok(config); + assert.ok(config.EnableCustomEmoji === 'true'); + assert.ok(config.EnableLinkPreviews === 'false'); + }); +}); \ No newline at end of file diff --git a/app/actions/websocket/general.ts b/app/actions/websocket/general.ts new file mode 100644 index 000000000..64dccfa27 --- /dev/null +++ b/app/actions/websocket/general.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {GeneralTypes} from '@mm-redux/action_types'; +import {General} from '@mm-redux/constants'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {GenericAction} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; + +export function handleConfigChangedEvent(msg: WebSocketMessage): GenericAction { + const data = msg.data.config; + + EventEmitter.emit(General.CONFIG_CHANGED, data); + return { + type: GeneralTypes.CLIENT_CONFIG_RECEIVED, + data, + }; +} + +export function handleLicenseChangedEvent(msg: WebSocketMessage): GenericAction { + const data = msg.data.license; + + return { + type: GeneralTypes.CLIENT_LICENSE_RECEIVED, + data, + }; +} \ No newline at end of file diff --git a/app/actions/websocket/groups.ts b/app/actions/websocket/groups.ts new file mode 100644 index 000000000..02a48e5c9 --- /dev/null +++ b/app/actions/websocket/groups.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {GroupTypes} from '@mm-redux/action_types'; +import {ActionResult, DispatchFunc, batchActions} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; + +export function handleGroupUpdatedEvent(msg: WebSocketMessage) { + return (dispatch: DispatchFunc): ActionResult => { + const data = JSON.parse(msg.data.group); + dispatch(batchActions([ + { + type: GroupTypes.RECEIVED_GROUP, + data, + }, + { + type: GroupTypes.RECEIVED_MY_GROUPS, + data: [data], + }, + ])); + return {data: true}; + }; +} \ No newline at end of file diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts new file mode 100644 index 000000000..774778602 --- /dev/null +++ b/app/actions/websocket/index.ts @@ -0,0 +1,403 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {loadChannelsForTeam} from '@actions/views/channel'; +import {getPosts} from '@actions/views/post'; +import {loadMe} from '@actions/views/user'; +import {WebsocketEvents} from '@constants'; +import {ChannelTypes, GeneralTypes, PreferenceTypes, TeamTypes, UserTypes, RoleTypes} from '@mm-redux/action_types'; +import {getProfilesByIds, getStatusesByIds} from '@mm-redux/actions/users'; +import {Client4} from '@mm-redux/client'; +import {General} from '@mm-redux/constants'; +import {getCurrentChannelId, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels'; +import {getConfig} from '@mm-redux/selectors/entities/general'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {getCurrentUserId, getUsers, getUserStatuses} from '@mm-redux/selectors/entities/users'; +import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions'; +import {Channel, ChannelMembership} from '@mm-redux/types/channels'; +import {GlobalState} from '@mm-redux/types/store'; +import {TeamMembership} from '@mm-redux/types/teams'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; +import {removeUserFromList} from '@mm-redux/utils/user_utils'; +import websocketClient from '@websocket'; + +import { + handleChannelConvertedEvent, + handleChannelCreatedEvent, + handleChannelDeletedEvent, + handleChannelMemberUpdatedEvent, + handleChannelSchemeUpdatedEvent, + handleChannelUnarchiveEvent, + handleChannelUpdatedEvent, + handleChannelViewedEvent, + handleDirectAddedEvent, + handleUpdateMemberRoleEvent, +} from './channels'; +import {handleConfigChangedEvent, handleLicenseChangedEvent} from './general'; +import {handleGroupUpdatedEvent} from './groups'; +import {handleOpenDialogEvent} from './integrations'; +import {handleNewPostEvent, handlePostDeleted, handlePostEdited, handlePostUnread} from './posts'; +import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePreferencesDeletedEvent} from './preferences'; +import {handleAddEmoji, handleReactionAddedEvent, handleReactionRemovedEvent} from './reactions'; +import {handleRoleAddedEvent, handleRoleRemovedEvent, handleRoleUpdatedEvent} from './roles'; +import {handleLeaveTeamEvent, handleUpdateTeamEvent, handleTeamAddedEvent} from './teams'; +import {handleStatusChangedEvent, handleUserAddedEvent, handleUserRemovedEvent, handleUserRoleUpdated, handleUserUpdatedEvent} from './users'; + +export function init(additionalOptions: any = {}) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const config = getConfig(getState()); + let connUrl = additionalOptions.websocketUrl || config.WebsocketURL || Client4.getUrl(); + const authToken = Client4.getToken(); + + connUrl += `${Client4.getUrlVersion()}/websocket`; + websocketClient.setFirstConnectCallback(() => dispatch(handleFirstConnect())); + websocketClient.setEventCallback((evt: WebSocketMessage) => dispatch(handleEvent(evt))); + websocketClient.setReconnectCallback(() => dispatch(handleReconnect())); + websocketClient.setCloseCallback((connectFailCount: number) => dispatch(handleClose(connectFailCount))); + + const websocketOpts = { + connectionUrl: connUrl, + ...additionalOptions, + }; + + return websocketClient.initialize(authToken, websocketOpts); + }; +} + +let reconnect = false; +export function close(shouldReconnect = false): GenericAction { + reconnect = shouldReconnect; + websocketClient.close(true); + + return { + type: GeneralTypes.WEBSOCKET_CLOSED, + timestamp: Date.now(), + data: null, + }; +} + +export function doFirstConnect(now: number) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const state = getState(); + const {lastDisconnectAt} = state.websocket; + const actions: Array = [{ + type: GeneralTypes.WEBSOCKET_SUCCESS, + timestamp: now, + data: null, + }]; + + if (isMinimumServerVersion(Client4.getServerVersion(), 5, 14) && lastDisconnectAt) { + const currentUserId = getCurrentUserId(state); + const users = getUsers(state); + const userIds = Object.keys(users); + const userUpdates = await Client4.getProfilesByIds(userIds, {since: lastDisconnectAt}); + + if (userUpdates.length) { + removeUserFromList(currentUserId, userUpdates); + actions.push({ + type: UserTypes.RECEIVED_PROFILES_LIST, + data: userUpdates, + }); + } + } + + dispatch(batchActions(actions, 'BATCH_WS_CONNCET')); + + return {data: true}; + }; +} + +export function doReconnect(now: number) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const state = getState(); + const currentTeamId = getCurrentTeamId(state); + const currentChannelId = getCurrentChannelId(state); + const currentUserId = getCurrentUserId(state); + const users = getUsers(state); + const {lastDisconnectAt} = state.websocket; + const actions: Array = []; + + dispatch({ + type: GeneralTypes.WEBSOCKET_SUCCESS, + timestamp: now, + data: null, + }); + + try { + const {data: me}: any = await dispatch(loadMe(null, null, true)); + + if (!me.error) { + const roles = []; + + if (me.roles?.length) { + roles.push(...me.roles); + } + + actions.push({ + type: PreferenceTypes.RECEIVED_ALL_PREFERENCES, + data: me.preferences, + }, { + type: TeamTypes.RECEIVED_MY_TEAM_UNREADS, + data: me.teamUnreads, + }, { + type: TeamTypes.RECEIVED_TEAMS_LIST, + data: me.teams, + }, { + type: TeamTypes.RECEIVED_MY_TEAM_MEMBERS, + data: me.teamMembers, + }); + + const currentTeamMembership = me.teamMembers.find((tm: TeamMembership) => tm.team_id === currentTeamId && tm.delete_at === 0); + + if (currentTeamMembership) { + const {data: myData}: any = await dispatch(loadChannelsForTeam(currentTeamId, true)); + + if (myData?.channels && myData?.channelMembers) { + actions.push({ + type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS, + data: myData, + }); + + const stillMemberOfCurrentChannel = myData.channelMembers.find((cm: ChannelMembership) => cm.channel_id === currentChannelId); + + const channelStillExists = myData.channels.find((c: Channel) => c.id === currentChannelId); + const config = me.config || getConfig(getState()); + const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; + if (!stillMemberOfCurrentChannel || !channelStillExists || (!viewArchivedChannels && channelStillExists.delete_at !== 0)) { + EventEmitter.emit(General.SWITCH_TO_DEFAULT_CHANNEL, currentTeamId); + } else { + dispatch(getPosts(currentChannelId)); + } + } + + if (myData.roles?.length) { + roles.push(...myData.roles); + } + } else { + // If the user is no longer a member of this team when reconnecting + const newMsg = { + data: { + user_id: currentUserId, + team_id: currentTeamId, + }, + }; + dispatch(handleLeaveTeamEvent(newMsg)); + } + + if (roles.length) { + actions.push({ + type: RoleTypes.RECEIVED_ROLES, + data: roles, + }); + } + + if (isMinimumServerVersion(Client4.getServerVersion(), 5, 14) && lastDisconnectAt) { + const userIds = Object.keys(users); + const userUpdates = await Client4.getProfilesByIds(userIds, {since: lastDisconnectAt}); + + if (userUpdates.length) { + removeUserFromList(currentUserId, userUpdates); + actions.push({ + type: UserTypes.RECEIVED_PROFILES_LIST, + data: userUpdates, + }); + } + } + + if (actions.length) { + dispatch(batchActions(actions, 'BATCH_WS_RECONNECT')); + } + } + } catch (e) { + // do nothing + } + + return {data: true}; + }; +} + +export function handleUserTypingEvent(msg: WebSocketMessage) { + return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => { + const state = getState(); + const currentChannelId = getCurrentChannelId(state); + + if (currentChannelId === msg.broadcast.channel_id) { + const profiles = getUsers(state); + const statuses = getUserStatuses(state); + const currentUserId = getCurrentUserId(state); + const config = getConfig(state); + const userId = msg.data.user_id; + + const data = { + id: msg.broadcast.channel_id + msg.data.parent_id, + userId, + now: Date.now(), + }; + + dispatch({ + type: WebsocketEvents.TYPING, + data, + }); + + setTimeout(() => { + const newState = getState(); + const {typing} = newState.entities; + + if (typing && typing[data.id]) { + dispatch({ + type: WebsocketEvents.STOP_TYPING, + data, + }); + } + }, parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds!, 10)); + + if (!profiles[userId] && userId !== currentUserId) { + dispatch(getProfilesByIds([userId])); + } + + const status = statuses[userId]; + if (status !== General.ONLINE) { + dispatch(getStatusesByIds([userId])); + } + } + + return {data: true}; + }; +} + +function handleFirstConnect() { + return (dispatch: DispatchFunc) => { + const now = Date.now(); + + if (reconnect) { + reconnect = false; + return dispatch(doReconnect(now)); + } + return dispatch(doFirstConnect(now)); + }; +} + +function handleReconnect() { + return (dispatch: DispatchFunc) => { + return dispatch(doReconnect(Date.now())); + }; +} + +function handleClose(connectFailCount: number) { + return { + type: GeneralTypes.WEBSOCKET_FAILURE, + error: connectFailCount, + data: null, + timestamp: Date.now(), + }; +} + +function handleEvent(msg: WebSocketMessage) { + return (dispatch: DispatchFunc) => { + switch (msg.event) { + case WebsocketEvents.POSTED: + case WebsocketEvents.EPHEMERAL_MESSAGE: + return dispatch(handleNewPostEvent(msg)); + case WebsocketEvents.POST_EDITED: + return dispatch(handlePostEdited(msg)); + case WebsocketEvents.POST_DELETED: + return dispatch(handlePostDeleted(msg)); + case WebsocketEvents.POST_UNREAD: + return dispatch(handlePostUnread(msg)); + case WebsocketEvents.LEAVE_TEAM: + return dispatch(handleLeaveTeamEvent(msg)); + case WebsocketEvents.UPDATE_TEAM: + return dispatch(handleUpdateTeamEvent(msg)); + case WebsocketEvents.ADDED_TO_TEAM: + return dispatch(handleTeamAddedEvent(msg)); + case WebsocketEvents.USER_ADDED: + return dispatch(handleUserAddedEvent(msg)); + case WebsocketEvents.USER_REMOVED: + return dispatch(handleUserRemovedEvent(msg)); + case WebsocketEvents.USER_UPDATED: + return dispatch(handleUserUpdatedEvent(msg)); + case WebsocketEvents.ROLE_ADDED: + return dispatch(handleRoleAddedEvent(msg)); + case WebsocketEvents.ROLE_REMOVED: + return dispatch(handleRoleRemovedEvent(msg)); + case WebsocketEvents.ROLE_UPDATED: + return dispatch(handleRoleUpdatedEvent(msg)); + case WebsocketEvents.USER_ROLE_UPDATED: + return dispatch(handleUserRoleUpdated(msg)); + case WebsocketEvents.MEMBERROLE_UPDATED: + return dispatch(handleUpdateMemberRoleEvent(msg)); + case WebsocketEvents.CHANNEL_CREATED: + return dispatch(handleChannelCreatedEvent(msg)); + case WebsocketEvents.CHANNEL_DELETED: + return dispatch(handleChannelDeletedEvent(msg)); + case WebsocketEvents.CHANNEL_UNARCHIVED: + return dispatch(handleChannelUnarchiveEvent(msg)); + case WebsocketEvents.CHANNEL_UPDATED: + return dispatch(handleChannelUpdatedEvent(msg)); + case WebsocketEvents.CHANNEL_CONVERTED: + return dispatch(handleChannelConvertedEvent(msg)); + case WebsocketEvents.CHANNEL_VIEWED: + return dispatch(handleChannelViewedEvent(msg)); + case WebsocketEvents.CHANNEL_MEMBER_UPDATED: + return dispatch(handleChannelMemberUpdatedEvent(msg)); + case WebsocketEvents.CHANNEL_SCHEME_UPDATED: + return dispatch(handleChannelSchemeUpdatedEvent(msg)); + case WebsocketEvents.DIRECT_ADDED: + return dispatch(handleDirectAddedEvent(msg)); + case WebsocketEvents.PREFERENCE_CHANGED: + return dispatch(handlePreferenceChangedEvent(msg)); + case WebsocketEvents.PREFERENCES_CHANGED: + return dispatch(handlePreferencesChangedEvent(msg)); + case WebsocketEvents.PREFERENCES_DELETED: + return dispatch(handlePreferencesDeletedEvent(msg)); + case WebsocketEvents.STATUS_CHANGED: + return dispatch(handleStatusChangedEvent(msg)); + case WebsocketEvents.TYPING: + return dispatch(handleUserTypingEvent(msg)); + case WebsocketEvents.HELLO: + handleHelloEvent(msg); + break; + case WebsocketEvents.REACTION_ADDED: + return dispatch(handleReactionAddedEvent(msg)); + case WebsocketEvents.REACTION_REMOVED: + return dispatch(handleReactionRemovedEvent(msg)); + case WebsocketEvents.EMOJI_ADDED: + return dispatch(handleAddEmoji(msg)); + case WebsocketEvents.LICENSE_CHANGED: + return dispatch(handleLicenseChangedEvent(msg)); + case WebsocketEvents.CONFIG_CHANGED: + return dispatch(handleConfigChangedEvent(msg)); + case WebsocketEvents.OPEN_DIALOG: + return dispatch(handleOpenDialogEvent(msg)); + case WebsocketEvents.RECEIVED_GROUP: + return dispatch(handleGroupUpdatedEvent(msg)); + } + + return {data: true}; + }; +} + +function handleHelloEvent(msg: WebSocketMessage) { + const serverVersion = msg.data.server_version; + if (serverVersion && Client4.serverVersion !== serverVersion) { + Client4.serverVersion = serverVersion; + EventEmitter.emit(General.SERVER_VERSION_CHANGED, serverVersion); + } +} + +// Helpers +let lastTimeTypingSent = 0; +export function userTyping(state: GlobalState, channelId: string, parentPostId: string): void { + const config = getConfig(state); + const t = Date.now(); + const stats = getCurrentChannelStats(state); + const membersInChannel = stats ? stats.member_count : 0; + + if (((t - lastTimeTypingSent) > parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds!, 10)) && + (membersInChannel < parseInt(config.MaxNotificationsPerChannel!, 10)) && (config.EnableUserTypingMessages === 'true')) { + websocketClient.userTyping(channelId, parentPostId); + lastTimeTypingSent = t; + } +} diff --git a/app/actions/websocket/integrations.test.js b/app/actions/websocket/integrations.test.js new file mode 100644 index 000000000..bbb54a3c5 --- /dev/null +++ b/app/actions/websocket/integrations.test.js @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; +import {Server, WebSocket as MockWebSocket} from 'mock-socket'; +import {Client4} from '@mm-redux/client'; + +import * as Actions from '@actions/websocket'; +import {WebsocketEvents} from '@constants'; + +import TestHelper from 'test/test_helper'; +import configureStore from 'test/test_store'; + +global.WebSocket = MockWebSocket; + +describe('Websocket Integration Events', () => { + let store; + let mockServer; + beforeAll(async () => { + store = await configureStore(); + await TestHelper.initBasic(Client4); + + const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:'); + mockServer = new Server(connUrl); + return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + }); + + afterAll(async () => { + Actions.close()(); + mockServer.stop(); + await TestHelper.tearDown(); + }); + + it('handle open dialog', async () => { + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.OPEN_DIALOG, data: {dialog: JSON.stringify({url: 'someurl', trigger_id: 'sometriggerid', dialog: {}})}})); + + await TestHelper.wait(200); + + const state = store.getState(); + + const dialog = state.entities.integrations.dialog; + assert.ok(dialog); + assert.ok(dialog.url === 'someurl'); + assert.ok(dialog.trigger_id === 'sometriggerid'); + assert.ok(dialog.dialog); + }); +}); \ No newline at end of file diff --git a/app/actions/websocket/integrations.ts b/app/actions/websocket/integrations.ts new file mode 100644 index 000000000..14af7b3fe --- /dev/null +++ b/app/actions/websocket/integrations.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {IntegrationTypes} from '@mm-redux/action_types'; +import {ActionResult, DispatchFunc} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; + +export function handleOpenDialogEvent(msg: WebSocketMessage) { + return (dispatch: DispatchFunc): ActionResult => { + const data = (msg.data && msg.data.dialog) || {}; + dispatch({type: IntegrationTypes.RECEIVED_DIALOG, data: JSON.parse(data)}); + return {data: true}; + }; +} \ No newline at end of file diff --git a/app/actions/websocket/posts.test.js b/app/actions/websocket/posts.test.js new file mode 100644 index 000000000..142691cec --- /dev/null +++ b/app/actions/websocket/posts.test.js @@ -0,0 +1,265 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-import-assign */ + +import assert from 'assert'; +import nock from 'nock'; +import {Server, WebSocket as MockWebSocket} from 'mock-socket'; + +import * as ChannelActions from '@mm-redux/actions/channels'; +import * as PostActions from '@mm-redux/actions/posts'; +import {Client4} from '@mm-redux/client'; +import {General, Posts} from '@mm-redux/constants'; +import * as PostSelectors from '@mm-redux/selectors/entities/posts'; +import EventEmitter from '@mm-redux/utils/event_emitter'; + +import * as Actions from '@actions/websocket'; +import {WebsocketEvents} from '@constants'; + +import TestHelper from 'test/test_helper'; +import configureStore from 'test/test_store'; + +global.WebSocket = MockWebSocket; + +describe('Websocket Post Events', () => { + let store; + let mockServer; + beforeAll(async () => { + store = await configureStore(); + await TestHelper.initBasic(Client4); + + const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:'); + mockServer = new Server(connUrl); + return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + }); + + afterAll(async () => { + Actions.close()(); + mockServer.stop(); + await TestHelper.tearDown(); + }); + + it('Websocket Handle New Post if post does not exist', async () => { + PostSelectors.getPost = jest.fn(); + const channelId = TestHelper.basicChannel.id; + const message = JSON.stringify({event: WebsocketEvents.POSTED, data: {channel_display_name: TestHelper.basicChannel.display_name, channel_name: TestHelper.basicChannel.name, channel_type: 'O', post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w", "create_at": 1508245311774, "update_at": 1508245311774, "edit_at": 0, "delete_at": 0, "is_pinned": false, "user_id": "${TestHelper.basicUser.id}", "channel_id": "${channelId}", "root_id": "", "parent_id": "", "original_id": "", "message": "Unit Test", "type": "", "props": {}, "hashtags": "", "pending_post_id": "t36kso9nwtdhbm8dbkd6g4eeby: 1508245311749"}`, sender_name: TestHelper.basicUser.username, team_id: TestHelper.basicTeam.id}, broadcast: {omit_users: null, user_id: '', channel_id: channelId, team_id: ''}, seq: 2}); + + nock(Client4.getBaseRoute()). + post('/users/ids'). + reply(200, [TestHelper.basicUser.id]); + + nock(Client4.getBaseRoute()). + post('/users/status/ids'). + reply(200, [{user_id: TestHelper.basicUser.id, status: 'online', manual: false, last_activity_at: 1507662212199}]); + + // Mock that post already exists and check it is not added + PostSelectors.getPost.mockReturnValueOnce(true); + mockServer.emit('message', message); + let entities = store.getState().entities; + let posts = entities.posts.posts; + assert.deepEqual(posts, {}); + + // Mock that post does not exist and check it is added + PostSelectors.getPost.mockReturnValueOnce(false); + mockServer.emit('message', message); + await TestHelper.wait(100); + entities = store.getState().entities; + posts = entities.posts.posts; + const postId = Object.keys(posts)[0]; + assert.ok(posts[postId].message.indexOf('Unit Test') > -1); + entities = store.getState().entities; + }); + + it('Websocket Handle New Post emits INCREASE_POST_VISIBILITY_BY_ONE for current channel when post does not exist', async () => { + PostSelectors.getPost = jest.fn(); + const emit = jest.spyOn(EventEmitter, 'emit'); + const currentChannelId = TestHelper.generateId(); + const otherChannelId = TestHelper.generateId(); + const messageFor = (channelId) => ({event: WebsocketEvents.POSTED, data: {channel_display_name: TestHelper.basicChannel.display_name, channel_name: TestHelper.basicChannel.name, channel_type: 'O', post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w", "create_at": 1508245311774, "update_at": 1508245311774, "edit_at": 0, "delete_at": 0, "is_pinned": false, "user_id": "${TestHelper.basicUser.id}", "channel_id": "${channelId}", "root_id": "", "parent_id": "", "original_id": "", "message": "Unit Test", "type": "", "props": {}, "hashtags": "", "pending_post_id": "t36kso9nwtdhbm8dbkd6g4eeby: 1508245311749"}`, sender_name: TestHelper.basicUser.username, team_id: TestHelper.basicTeam.id}, broadcast: {omit_users: null, user_id: '', channel_id: channelId, team_id: ''}, seq: 2}); + + await store.dispatch(ChannelActions.selectChannel(currentChannelId)); + await TestHelper.wait(100); + + // Post does not exist and is not for current channel + PostSelectors.getPost.mockReturnValueOnce(false); + mockServer.emit('message', JSON.stringify(messageFor(otherChannelId))); + expect(emit).not.toHaveBeenCalled(); + + // Post exists and is not for current channel + PostSelectors.getPost.mockReturnValueOnce(true); + mockServer.emit('message', JSON.stringify(messageFor(otherChannelId))); + expect(emit).not.toHaveBeenCalled(); + + // Post exists and is for current channel + PostSelectors.getPost.mockReturnValueOnce(true); + mockServer.emit('message', JSON.stringify(messageFor(currentChannelId))); + expect(emit).not.toHaveBeenCalled(); + + // Post does not exist and is for current channel + PostSelectors.getPost.mockReturnValueOnce(false); + mockServer.emit('message', JSON.stringify(messageFor(currentChannelId))); + expect(emit).toHaveBeenCalledWith(WebsocketEvents.INCREASE_POST_VISIBILITY_BY_ONE); + }); + + it('Websocket Handle New Post if status is manually set do not set to online', async () => { + const userId = TestHelper.generateId(); + + store = await configureStore({ + entities: { + users: { + statuses: { + [userId]: General.DND, + }, + isManualStatus: { + [userId]: true, + }, + }, + }, + }); + await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + + const channelId = TestHelper.basicChannel.id; + const message = JSON.stringify({ + event: WebsocketEvents.POSTED, + data: { + channel_display_name: TestHelper.basicChannel.display_name, + channel_name: TestHelper.basicChannel.name, + channel_type: 'O', + post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w", "create_at": 1508245311774, "update_at": 1508245311774, "edit_at": 0, "delete_at": 0, "is_pinned": false, "user_id": "${userId}", "channel_id": "${channelId}", "root_id": "", "parent_id": "", "original_id": "", "message": "Unit Test", "type": "", "props": {}, "hashtags": "", "pending_post_id": "t36kso9nwtdhbm8dbkd6g4eeby: 1508245311749"}`, + sender_name: TestHelper.basicUser.username, + team_id: TestHelper.basicTeam.id, + }, + broadcast: { + omit_users: null, + user_id: userId, + channel_id: channelId, + team_id: '', + }, + seq: 2, + }); + + mockServer.emit('message', message); + const entities = store.getState().entities; + const statuses = entities.users.statuses; + assert.equal(statuses[userId], General.DND); + }); + + it('Websocket Handle Post Edited', async () => { + const post = {id: '71k8gz5ompbpfkrzaxzodffj8w'}; + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.POST_EDITED, data: {post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w","create_at": 1508245311774,"update_at": 1585236976007,"edit_at": 1585236976007,"delete_at": 0,"is_pinned": false,"user_id": "${TestHelper.basicUser.id}","channel_id": "${TestHelper.basicChannel.id}","root_id": "","parent_id": "","original_id": "","message": "Unit Test (edited)","type": "","props": {},"hashtags": "","pending_post_id": ""}`}, broadcast: {omit_users: null, user_id: '', channel_id: '18k9ffsuci8xxm7ak68zfdyrce', team_id: ''}, seq: 2})); + + await TestHelper.wait(300); + + const {posts} = store.getState().entities.posts; + assert.ok(posts); + assert.ok(posts[post.id]); + assert.ok(posts[post.id].message.indexOf('(edited)') > -1); + }); + + it('Websocket Handle Post Deleted', async () => { + const post = TestHelper.fakePost(); + post.channel_id = TestHelper.basicChannel.id; + + post.id = '71k8gz5ompbpfkrzaxzodffj8w'; + store.dispatch(PostActions.receivedPost(post)); + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.POST_DELETED, data: {post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w","create_at": 1508245311774,"update_at": 1508247709215,"edit_at": 1508247709215,"delete_at": 0,"is_pinned": false,"user_id": "${TestHelper.basicUser.id}","channel_id": "${post.channel_id}","root_id": "","parent_id": "","original_id": "","message": "Unit Test","type": "","props": {},"hashtags": "","pending_post_id": ""}`}, broadcast: {omit_users: null, user_id: '', channel_id: '18k9ffsuci8xxm7ak68zfdyrce', team_id: ''}, seq: 7})); + + const entities = store.getState().entities; + const {posts} = entities.posts; + assert.strictEqual(posts[post.id].state, Posts.POST_DELETED); + }); + + it('Websocket handle Post Unread', async () => { + const teamId = TestHelper.generateId(); + const channelId = TestHelper.generateId(); + const userId = TestHelper.generateId(); + + store = await configureStore({ + entities: { + channels: { + channels: { + [channelId]: {id: channelId}, + }, + myMembers: { + [channelId]: {msg_count: 10, mention_count: 0, last_viewed_at: 0}, + }, + }, + teams: { + myMembers: { + [teamId]: {msg_count: 10, mention_count: 0}, + }, + }, + }, + }); + await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + + mockServer.emit('message', JSON.stringify({ + event: WebsocketEvents.POST_UNREAD, + data: { + last_viewed_at: 25, + msg_count: 3, + mention_count: 2, + delta_msg: 7, + }, + broadcast: {omit_users: null, user_id: userId, channel_id: channelId, team_id: teamId}, + seq: 7, + })); + + const state = store.getState(); + assert.equal(state.entities.channels.manuallyUnread[channelId], true); + assert.equal(state.entities.channels.myMembers[channelId].msg_count, 3); + assert.equal(state.entities.channels.myMembers[channelId].mention_count, 2); + assert.equal(state.entities.channels.myMembers[channelId].last_viewed_at, 25); + assert.equal(state.entities.teams.myMembers[teamId].msg_count, 3); + assert.equal(state.entities.teams.myMembers[teamId].mention_count, 2); + }); + + it('Websocket handle Post Unread When marked on the same client', async () => { + const teamId = TestHelper.generateId(); + const channelId = TestHelper.generateId(); + const userId = TestHelper.generateId(); + + store = await configureStore({ + entities: { + channels: { + channels: { + [channelId]: {id: channelId}, + }, + myMembers: { + [channelId]: {msg_count: 5, mention_count: 4, last_viewed_at: 14}, + }, + manuallyUnread: { + [channelId]: true, + }, + }, + teams: { + myMembers: { + [teamId]: {msg_count: 5, mention_count: 4}, + }, + }, + }, + }); + await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + + mockServer.emit('message', JSON.stringify({ + event: WebsocketEvents.POST_UNREAD, + data: { + last_viewed_at: 25, + msg_count: 5, + mention_count: 4, + delta_msg: 1, + }, + broadcast: {omit_users: null, user_id: userId, channel_id: channelId, team_id: teamId}, + seq: 17, + })); + + const state = store.getState(); + assert.equal(state.entities.channels.manuallyUnread[channelId], true); + assert.equal(state.entities.channels.myMembers[channelId].msg_count, 5); + assert.equal(state.entities.channels.myMembers[channelId].mention_count, 4); + assert.equal(state.entities.channels.myMembers[channelId].last_viewed_at, 14); + assert.equal(state.entities.teams.myMembers[teamId].msg_count, 5); + assert.equal(state.entities.teams.myMembers[teamId].mention_count, 4); + }); +}); diff --git a/app/actions/websocket/posts.ts b/app/actions/websocket/posts.ts new file mode 100644 index 000000000..aba70dea7 --- /dev/null +++ b/app/actions/websocket/posts.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + fetchMyChannel, + fetchMyChannelMember, + makeDirectChannelVisibleIfNecessary, + makeGroupMessageVisibleIfNecessary, + markChannelAsUnread, +} from '@actions/helpers/channels'; +import {markAsViewedAndReadBatch} from '@actions/views/channel'; +import {getPostsAdditionalDataBatch, getPostThread} from '@actions/views/post'; +import {WebsocketEvents} from '@constants'; +import {ChannelTypes} from '@mm-redux/action_types'; +import {getUnreadPostData, postDeleted, receivedNewPost, receivedPost} from '@mm-redux/actions/posts'; +import {General} from '@mm-redux/constants'; +import { + getChannel, + getCurrentChannelId, + getMyChannelMember as selectMyChannelMember, + isManuallyUnread, +} from '@mm-redux/selectors/entities/channels'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; +import {getPost as selectPost} from '@mm-redux/selectors/entities/posts'; +import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {isFromWebhook, isSystemMessage, shouldIgnorePost} from '@mm-redux/utils/post_utils'; +import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; + +export function handleNewPostEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const state = getState(); + const currentChannelId = getCurrentChannelId(state); + const currentUserId = getCurrentUserId(state); + const data = JSON.parse(msg.data.post); + const post = { + ...data, + ownPost: data.user_id === currentUserId, + }; + + const actions: Array = []; + + const exists = selectPost(state, post.pending_post_id); + + if (!exists) { + if (getCurrentChannelId(state) === post.channel_id) { + EventEmitter.emit(WebsocketEvents.INCREASE_POST_VISIBILITY_BY_ONE); + } + + const myChannel = getChannel(state, post.channel_id); + if (!myChannel) { + const channel = await fetchMyChannel(post.channel_id); + if (channel.data) { + actions.push({ + type: ChannelTypes.RECEIVED_CHANNEL, + data: channel.data, + }); + } + } + + const myChannelMember = selectMyChannelMember(state, post.channel_id); + if (!myChannelMember) { + const member = await fetchMyChannelMember(post.channel_id); + if (member.data) { + actions.push({ + type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, + data: member.data, + }); + } + } + + actions.push(receivedNewPost(post)); + + // If we don't have the thread for this post, fetch it from the server + // and include the actions in the batch + if (post.root_id) { + const rootPost = selectPost(state, post.root_id); + + if (!rootPost) { + const thread: any = await dispatch(getPostThread(post.root_id, true)); + if (thread.data?.length) { + actions.push(...thread.data); + } + } + } + + if (post.channel_id === currentChannelId) { + const id = post.channel_id + post.root_id; + const {typing} = state.entities; + + if (typing[id]) { + actions.push({ + type: WebsocketEvents.STOP_TYPING, + data: { + id, + userId: post.user_id, + now: Date.now(), + }, + }); + } + } + + // Fetch and batch additional post data + const additional: any = await dispatch(getPostsAdditionalDataBatch([post])); + if (additional.data.length) { + actions.push(...additional.data); + } + + if (msg.data.channel_type === General.DM_CHANNEL) { + const otherUserId = getUserIdFromChannelName(currentUserId, msg.data.channel_name); + const dmAction = makeDirectChannelVisibleIfNecessary(state, otherUserId); + if (dmAction) { + actions.push(dmAction); + } + } else if (msg.data.channel_type === General.GM_CHANNEL) { + const gmActions = await makeGroupMessageVisibleIfNecessary(state, post.channel_id); + if (gmActions) { + actions.push(...gmActions); + } + } + + if (!shouldIgnorePost(post)) { + let markAsRead = false; + let markAsReadOnServer = false; + + if (!isManuallyUnread(state, post.channel_id)) { + if ( + post.user_id === getCurrentUserId(state) && + !isSystemMessage(post) && + !isFromWebhook(post) + ) { + markAsRead = true; + markAsReadOnServer = false; + } else if (post.channel_id === currentChannelId) { + markAsRead = true; + markAsReadOnServer = true; + } + } + + if (markAsRead) { + const readActions = markAsViewedAndReadBatch(state, post.channel_id, undefined, markAsReadOnServer); + actions.push(...readActions); + } else { + const unreadActions = markChannelAsUnread(state, msg.data.team_id, post.channel_id, msg.data.mentions); + actions.push(...unreadActions); + } + } + + dispatch(batchActions(actions, 'BATCH_WS_NEW_POST')); + } + + return {data: true}; + }; +} + +export function handlePostEdited(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const state = getState(); + const currentUserId = getCurrentUserId(state); + const data = JSON.parse(msg.data.post); + const post = { + ...data, + ownPost: data.user_id === currentUserId, + }; + const actions = [receivedPost(post)]; + + const additional: any = await dispatch(getPostsAdditionalDataBatch([post])); + if (additional.data.length) { + actions.push(...additional.data); + } + + dispatch(batchActions(actions, 'BATCH_WS_POST_EDITED')); + return {data: true}; + }; +} + +export function handlePostDeleted(msg: WebSocketMessage): GenericAction { + const data = JSON.parse(msg.data.post); + + return postDeleted(data); +} + +export function handlePostUnread(msg: WebSocketMessage) { + return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => { + const state = getState(); + const manual = isManuallyUnread(state, msg.broadcast.channel_id); + + if (!manual) { + const member = selectMyChannelMember(state, msg.broadcast.channel_id); + const delta = member ? member.msg_count - msg.data.msg_count : msg.data.msg_count; + const info = { + ...msg.data, + user_id: msg.broadcast.user_id, + team_id: msg.broadcast.team_id, + channel_id: msg.broadcast.channel_id, + deltaMsgs: delta, + }; + const data = getUnreadPostData(info, state); + dispatch({ + type: ChannelTypes.POST_UNREAD_SUCCESS, + data, + }); + return {data}; + } + + return {data: null}; + }; +} \ No newline at end of file diff --git a/app/actions/websocket/preferences.ts b/app/actions/websocket/preferences.ts new file mode 100644 index 000000000..87bf07f6b --- /dev/null +++ b/app/actions/websocket/preferences.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getAddedDmUsersIfNecessary} from '@actions/helpers/channels'; +import {getPost} from '@actions/views/post'; +import {PreferenceTypes} from '@mm-redux/action_types'; +import {Preferences} from '@mm-redux/constants'; +import {getAllPosts} from '@mm-redux/selectors/entities/posts'; +import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions'; +import {PreferenceType} from '@mm-redux/types/preferences'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; + +export function handlePreferenceChangedEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const preference = JSON.parse(msg.data.preference); + const actions: Array = [{ + type: PreferenceTypes.RECEIVED_PREFERENCES, + data: [preference], + }]; + + const dmActions = await getAddedDmUsersIfNecessary(getState(), [preference]); + if (dmActions.length) { + actions.push(...dmActions); + } + + dispatch(batchActions(actions, 'BATCH_WS_PREFERENCE_CHANGED')); + return {data: true}; + }; +} + +export function handlePreferencesChangedEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const preferences: PreferenceType[] = JSON.parse(msg.data.preferences); + const posts = getAllPosts(getState()); + const actions: Array = [{ + type: PreferenceTypes.RECEIVED_PREFERENCES, + data: preferences, + }]; + + preferences.forEach((pref) => { + if (pref.category === Preferences.CATEGORY_FLAGGED_POST && !posts[pref.name]) { + dispatch(getPost(pref.name)); + } + }); + + const dmActions = await getAddedDmUsersIfNecessary(getState(), preferences); + if (dmActions.length) { + actions.push(...dmActions); + } + + dispatch(batchActions(actions, 'BATCH_WS_PREFERENCES_CHANGED')); + return {data: true}; + }; +} + +export function handlePreferencesDeletedEvent(msg: WebSocketMessage): GenericAction { + const preferences = JSON.parse(msg.data.preferences); + + return {type: PreferenceTypes.DELETED_PREFERENCES, data: preferences}; +} \ No newline at end of file diff --git a/app/actions/websocket/reactions.test.js b/app/actions/websocket/reactions.test.js new file mode 100644 index 000000000..da04918bf --- /dev/null +++ b/app/actions/websocket/reactions.test.js @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; +import {Server, WebSocket as MockWebSocket} from 'mock-socket'; + +import {Client4} from '@mm-redux/client'; + +import * as Actions from '@actions/websocket'; +import {WebsocketEvents} from '@constants'; + +import TestHelper from 'test/test_helper'; +import configureStore from 'test/test_store'; + +global.WebSocket = MockWebSocket; + +describe('Websocket Reaction Events', () => { + let store; + let mockServer; + beforeAll(async () => { + store = await configureStore(); + await TestHelper.initBasic(Client4); + + const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:'); + mockServer = new Server(connUrl); + return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + }); + + afterAll(async () => { + Actions.close()(); + mockServer.stop(); + await TestHelper.tearDown(); + }); + + it('Websocket Handle Reaction Added to Post', async () => { + const emoji = '+1'; + const post = {id: 'w7yo9377zbfi9mgiq5gbfpn3ha'}; + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.REACTION_ADDED, data: {reaction: `{"user_id":"${TestHelper.basicUser.id}","post_id":"w7yo9377zbfi9mgiq5gbfpn3ha","emoji_name":"${emoji}","create_at":1508249125852}`}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 12})); + + await TestHelper.wait(300); + const nextEntities = store.getState().entities; + const {reactions} = nextEntities.posts; + const reactionsForPost = reactions[post.id]; + + assert.ok(reactionsForPost.hasOwnProperty(`${TestHelper.basicUser.id}-${emoji}`)); + }); + + it('Websocket handle emoji added', async () => { + const created = {id: '1mmgakhhupfgfm8oug6pooc5no'}; + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.EMOJI_ADDED, data: {emoji: `{"id":"1mmgakhhupfgfm8oug6pooc5no","create_at":1508263941321,"update_at":1508263941321,"delete_at":0,"creator_id":"t36kso9nwtdhbm8dbkd6g4eeby","name":"${TestHelper.generateId()}"}`}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 2})); + + await TestHelper.wait(200); + + const state = store.getState(); + + const emojis = state.entities.emojis.customEmoji; + assert.ok(emojis); + assert.ok(emojis[created.id]); + }); +}); \ No newline at end of file diff --git a/app/actions/websocket/reactions.ts b/app/actions/websocket/reactions.ts new file mode 100644 index 000000000..8a46282fb --- /dev/null +++ b/app/actions/websocket/reactions.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {EmojiTypes, PostTypes} from '@mm-redux/action_types'; +import {getCustomEmojiForReaction} from '@mm-redux/actions/posts'; +import {ActionResult, DispatchFunc, GenericAction} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; + +export function handleAddEmoji(msg: WebSocketMessage): GenericAction { + const data = JSON.parse(msg.data.emoji); + + return { + type: EmojiTypes.RECEIVED_CUSTOM_EMOJI, + data, + }; +} + +export function handleReactionAddedEvent(msg: WebSocketMessage) { + return (dispatch: DispatchFunc): ActionResult => { + const {data} = msg; + const reaction = JSON.parse(data.reaction); + + dispatch(getCustomEmojiForReaction(reaction.emoji_name)); + + dispatch({ + type: PostTypes.RECEIVED_REACTION, + data: reaction, + }); + return {data: true}; + }; +} + +export function handleReactionRemovedEvent(msg: WebSocketMessage): GenericAction { + const {data} = msg; + const reaction = JSON.parse(data.reaction); + + return { + type: PostTypes.REACTION_DELETED, + data: reaction, + }; +} \ No newline at end of file diff --git a/app/actions/websocket/roles.ts b/app/actions/websocket/roles.ts new file mode 100644 index 000000000..36f08c02b --- /dev/null +++ b/app/actions/websocket/roles.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {RoleTypes} from '@mm-redux/action_types'; +import {GenericAction} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; + +export function handleRoleAddedEvent(msg: WebSocketMessage): GenericAction { + const role = JSON.parse(msg.data.role); + + return { + type: RoleTypes.RECEIVED_ROLE, + data: role, + }; +} + +export function handleRoleRemovedEvent(msg: WebSocketMessage): GenericAction { + const role = JSON.parse(msg.data.role); + + return { + type: RoleTypes.ROLE_DELETED, + data: role, + }; +} + +export function handleRoleUpdatedEvent(msg: WebSocketMessage): GenericAction { + const role = JSON.parse(msg.data.role); + + return { + type: RoleTypes.RECEIVED_ROLE, + data: role, + }; +} \ No newline at end of file diff --git a/app/actions/websocket/teams.test.js b/app/actions/websocket/teams.test.js new file mode 100644 index 000000000..a04a79d26 --- /dev/null +++ b/app/actions/websocket/teams.test.js @@ -0,0 +1,106 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; +import nock from 'nock'; +import {Server, WebSocket as MockWebSocket} from 'mock-socket'; +import {batchActions} from 'redux-batched-actions'; + +import {TeamTypes, UserTypes} from '@mm-redux/action_types'; +import {Client4} from '@mm-redux/client'; + +import * as Actions from '@actions/websocket'; +import {WebsocketEvents} from '@constants'; + +import TestHelper from 'test/test_helper'; +import configureStore from 'test/test_store'; + +global.WebSocket = MockWebSocket; + +describe('Websocket Team Events', () => { + let store; + let mockServer; + beforeAll(async () => { + store = await configureStore(); + await TestHelper.initBasic(Client4); + + const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:'); + mockServer = new Server(connUrl); + store.dispatch(batchActions([ + {type: UserTypes.RECEIVED_ME, data: TestHelper.basicUser}, + {type: TeamTypes.RECEIVED_TEAM, data: TestHelper.basicTeam}, + {type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, data: TestHelper.basicTeamMember}, + {type: TeamTypes.RECEIVED_MY_TEAM_UNREADS, data: [TestHelper.basicTeamMember]}, + ])); + return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + }); + + afterAll(async () => { + Actions.close()(); + mockServer.stop(); + await TestHelper.tearDown(); + }); + + // If we move this test lower it will fail cause of a permissions issue + it('Websocket handle team updated', async () => { + const team = {id: '55pfercbm7bsmd11p5cjpgsbwr'}; + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.UPDATE_TEAM, data: {team: `{"id":"55pfercbm7bsmd11p5cjpgsbwr","create_at":1495553950859,"update_at":1508250370054,"delete_at":0,"display_name":"${TestHelper.basicTeam.display_name}","name":"${TestHelper.basicTeam.name}","description":"description","email":"","type":"O","company_name":"","allowed_domains":"","invite_id":"m93f54fu5bfntewp8ctwonw19w","allow_open_invite":true}`}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 26})); + + await TestHelper.wait(300); + + const entities = store.getState().entities; + const {teams} = entities.teams; + const updated = teams[team.id]; + assert.ok(updated); + assert.strictEqual(updated.allow_open_invite, true); + }); + + it('Websocket handle team patched', async () => { + const team = {id: '55pfercbm7bsmd11p5cjpgsbwr'}; + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.UPDATE_TEAM, data: {team: `{"id":"55pfercbm7bsmd11p5cjpgsbwr","create_at":1495553950859,"update_at":1508250370054,"delete_at":0,"display_name":"${TestHelper.basicTeam.display_name}","name":"${TestHelper.basicTeam.name}","description":"description","email":"","type":"O","company_name":"","allowed_domains":"","invite_id":"m93f54fu5bfntewp8ctwonw19w","allow_open_invite":true}`}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 26})); + + await TestHelper.wait(300); + + const entities = store.getState().entities; + const {teams} = entities.teams; + const updated = teams[team.id]; + assert.ok(updated); + assert.strictEqual(updated.allow_open_invite, true); + }); + + it('Websocket handle user added to team', async () => { + const team = TestHelper.basicTeam; + + nock(Client4.getBaseRoute()). + get(`/teams/${team.id}`). + reply(200, team); + + nock(Client4.getBaseRoute()). + get('/users/me/teams/unread'). + reply(200, [{team_id: team.id, msg_count: 0, mention_count: 0}]); + + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.ADDED_TO_TEAM, data: {team_id: team.id, user_id: TestHelper.basicUser.id}, broadcast: {omit_users: null, user_id: TestHelper.basicUser.id, channel_id: '', team_id: ''}, seq: 2})); + + await TestHelper.wait(300); + + const {teams, myMembers} = store.getState().entities.teams; + assert.ok(teams[team.id]); + assert.ok(myMembers[team.id]); + + const member = myMembers[team.id]; + assert.ok(member.hasOwnProperty('mention_count')); + }); + + it('WebSocket Leave Team', async () => { + const team = TestHelper.basicTeam; + store.dispatch(batchActions([ + {type: UserTypes.RECEIVED_ME, data: TestHelper.basicUser}, + {type: TeamTypes.RECEIVED_TEAM, data: TestHelper.basicTeam}, + {type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, data: TestHelper.basicTeamMember}, + ])); + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.LEAVE_TEAM, data: {team_id: team.id, user_id: TestHelper.basicUser.id}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: team.id}, seq: 35})); + + const {myMembers} = store.getState().entities.teams; + assert.ifError(myMembers[team.id]); + }); +}); \ No newline at end of file diff --git a/app/actions/websocket/teams.ts b/app/actions/websocket/teams.ts new file mode 100644 index 000000000..9f08b814b --- /dev/null +++ b/app/actions/websocket/teams.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {RoleTypes, TeamTypes} from '@mm-redux/action_types'; +import {notVisibleUsersActions} from '@mm-redux/actions/helpers'; +import {Client4} from '@mm-redux/client'; +import {getCurrentTeamId, getTeams as getTeamsSelector} from '@mm-redux/selectors/entities/teams'; +import {getCurrentUser} from '@mm-redux/selectors/entities/users'; +import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {isGuest} from '@mm-redux/utils/user_utils'; + +export function handleLeaveTeamEvent(msg: Partial) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const state = getState(); + const teams = getTeamsSelector(state); + const currentTeamId = getCurrentTeamId(state); + const currentUser = getCurrentUser(state); + + if (currentUser.id === msg.data.user_id) { + const actions: Array = [{type: TeamTypes.LEAVE_TEAM, data: teams[msg.data.team_id]}]; + if (isGuest(currentUser.roles)) { + const notVisible = await notVisibleUsersActions(state); + if (notVisible.length) { + actions.push(...notVisible); + } + } + dispatch(batchActions(actions, 'BATCH_WS_LEAVE_TEAM')); + + // if they are on the team being removed deselect the current team and channel + if (currentTeamId === msg.data.team_id) { + EventEmitter.emit('leave_team'); + } + } + return {data: true}; + }; +} + +export function handleUpdateTeamEvent(msg: WebSocketMessage): GenericAction { + return { + type: TeamTypes.UPDATED_TEAM, + data: JSON.parse(msg.data.team), + }; +} + +export function handleTeamAddedEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc): Promise => { + try { + const teamId = msg.data.team_id; + const userId = msg.data.user_id; + const [team, member, teamUnreads] = await Promise.all([ + Client4.getTeam(msg.data.team_id), + Client4.getTeamMember(teamId, userId), + Client4.getMyTeamUnreads(), + ]); + + const actions = []; + if (team) { + actions.push({ + type: TeamTypes.RECEIVED_TEAM, + data: team, + }); + + if (member) { + actions.push({ + type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, + data: member, + }); + + if (member.roles) { + const rolesToLoad = new Set(); + for (const role of member.roles.split(' ')) { + rolesToLoad.add(role); + } + + if (rolesToLoad.size > 0) { + const roles = await Client4.getRolesByNames(Array.from(rolesToLoad)); + if (roles.length) { + actions.push({ + type: RoleTypes.RECEIVED_ROLES, + data: roles, + }); + } + } + } + } + + if (teamUnreads) { + actions.push({ + type: TeamTypes.RECEIVED_MY_TEAM_UNREADS, + data: teamUnreads, + }); + } + } + + if (actions.length) { + dispatch(batchActions(actions, 'BATCH_WS_TEAM_ADDED')); + } + } catch { + // do nothing + } + + return {data: true}; + }; +} \ No newline at end of file diff --git a/app/actions/websocket/users.test.js b/app/actions/websocket/users.test.js new file mode 100644 index 000000000..735f95470 --- /dev/null +++ b/app/actions/websocket/users.test.js @@ -0,0 +1,94 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; +import {Server, WebSocket as MockWebSocket} from 'mock-socket'; +import {batchActions} from 'redux-batched-actions'; +import {TeamTypes, UserTypes} from '@mm-redux/action_types'; +import {Client4} from '@mm-redux/client'; + +import * as Actions from '@actions/websocket'; +import {WebsocketEvents} from '@constants'; + +import TestHelper from 'test/test_helper'; +import configureStore from 'test/test_store'; + +global.WebSocket = MockWebSocket; + +describe('Websocket User Events', () => { + let store; + let mockServer; + beforeAll(async () => { + store = await configureStore(); + await TestHelper.initBasic(Client4); + + const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:'); + mockServer = new Server(connUrl); + store.dispatch(batchActions([ + {type: UserTypes.RECEIVED_ME, data: TestHelper.basicUser}, + {type: TeamTypes.RECEIVED_TEAM, data: TestHelper.basicTeam}, + {type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, data: TestHelper.basicTeamMember}, + ])); + return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + }); + + afterAll(async () => { + Actions.close()(); + mockServer.stop(); + await TestHelper.tearDown(); + }); + + it('Websocket Handle User Added', async () => { + const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()}; + store.dispatch({type: UserTypes.RECEIVED_PROFILE_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}}); + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_ADDED, data: {team_id: TestHelper.basicTeam.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42})); + + 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 () => { + const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()}; + store.dispatch({type: UserTypes.RECEIVED_PROFILE_NOT_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}}); + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_REMOVED, data: {remover_id: TestHelper.basicUser.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42})); + + const state = store.getState(); + const entities = state.entities; + const profilesNotInChannel = entities.users.profilesNotInChannel; + + assert.ok(profilesNotInChannel[TestHelper.basicChannel.id].has(user.id)); + }); + + it('Websocket Handle User Removed when Current is Guest', async () => { + const basicGuestUser = TestHelper.fakeUserWithId(); + basicGuestUser.roles = 'system_guest'; + + const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()}; + + // add user first + store.dispatch({type: UserTypes.RECEIVED_PROFILE_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}}); + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_ADDED, data: {team_id: TestHelper.basicTeam.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42})); + + assert.ok(store.getState().entities.users.profilesInChannel[TestHelper.basicChannel.id].has(user.id)); + + // remove user + store.dispatch({type: UserTypes.RECEIVED_PROFILE_NOT_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}}); + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_REMOVED, data: {remover_id: basicGuestUser.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42})); + + assert.ok(!store.getState().entities.users.profilesInChannel[TestHelper.basicChannel.id].has(user.id)); + }); + + it('Websocket Handle User Updated', async () => { + const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()}; + mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_UPDATED, data: {user: {id: user.id, create_at: 1495570297229, update_at: 1508253268652, delete_at: 0, username: 'tim', auth_data: '', auth_service: '', email: 'tim@bladekick.com', nickname: '', first_name: 'tester4', last_name: '', position: '', roles: 'system_user', locale: 'en'}}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 53})); + + store.subscribe(() => { + const state = store.getState(); + const entities = state.entities; + const profiles = entities.users.profiles; + + assert.strictEqual(profiles[user.id].first_name, 'tester4'); + }); + }); +}); \ No newline at end of file diff --git a/app/actions/websocket/users.ts b/app/actions/websocket/users.ts new file mode 100644 index 000000000..aa89fa4d8 --- /dev/null +++ b/app/actions/websocket/users.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchChannelAndMyMember} from '@actions/helpers/channels'; +import {loadChannelsForTeam} from '@actions/views/channel'; +import {getMe} from '@actions/views/user'; +import {ChannelTypes, TeamTypes, UserTypes, RoleTypes} from '@mm-redux/action_types'; +import {notVisibleUsersActions} from '@mm-redux/actions/helpers'; +import {Client4} from '@mm-redux/client'; +import {General} from '@mm-redux/constants'; +import {getAllChannels, getCurrentChannelId, getChannelMembersInChannels} from '@mm-redux/selectors/entities/channels'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {getCurrentUser, getCurrentUserId} from '@mm-redux/selectors/entities/users'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; +import {isGuest} from '@mm-redux/utils/user_utils'; + +export function handleStatusChangedEvent(msg: WebSocketMessage): GenericAction { + return { + type: UserTypes.RECEIVED_STATUSES, + data: [{user_id: msg.data.user_id, status: msg.data.status}], + }; +} + +export function handleUserAddedEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + try { + const state = getState(); + const currentChannelId = getCurrentChannelId(state); + const currentTeamId = getCurrentTeamId(state); + const currentUserId = getCurrentUserId(state); + const teamId = msg.data.team_id; + const actions: Array = [{ + type: ChannelTypes.CHANNEL_MEMBER_ADDED, + data: { + channel_id: msg.broadcast.channel_id, + user_id: msg.data.user_id, + }, + }]; + + if (msg.broadcast.channel_id === currentChannelId) { + const stat = await Client4.getChannelStats(currentChannelId); + actions.push({ + type: ChannelTypes.RECEIVED_CHANNEL_STATS, + data: stat, + }); + } + + if (teamId === currentTeamId && msg.data.user_id === currentUserId) { + const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id); + + if (channelActions.length) { + actions.push(...channelActions); + } + } + + dispatch(batchActions(actions, 'BATCH_WS_USER_ADDED')); + } catch (error) { + //do nothing + } + return {data: true}; + }; +} + +export function handleUserRemovedEvent(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + try { + const state = getState(); + const channels = getAllChannels(state); + const currentChannelId = getCurrentChannelId(state); + const currentTeamId = getCurrentTeamId(state); + const currentUser = getCurrentUser(state); + const actions: Array = []; + let channelId; + let userId; + + if (msg.data.user_id) { + userId = msg.data.user_id; + channelId = msg.broadcast.channel_id; + } else if (msg.broadcast.user_id) { + channelId = msg.data.channel_id; + userId = msg.broadcast.user_id; + } + + if (userId) { + actions.push({ + type: ChannelTypes.CHANNEL_MEMBER_REMOVED, + data: { + channel_id: channelId, + user_id: userId, + }, + }); + } + + const channel = channels[currentChannelId]; + + if (msg.data?.user_id !== currentUser.id) { + const members = getChannelMembersInChannels(state); + const isMember = Object.values(members).some((member) => member[msg.data.user_id]); + if (channel && isGuest(currentUser.roles) && !isMember) { + actions.push({ + type: UserTypes.PROFILE_NO_LONGER_VISIBLE, + data: {user_id: msg.data.user_id}, + }, { + type: TeamTypes.REMOVE_MEMBER_FROM_TEAM, + data: {team_id: channel.team_id, user_id: msg.data.user_id}, + }); + } + } + + let redirectToDefaultChannel = false; + if (msg.broadcast.user_id === currentUser.id && currentTeamId) { + const {data: myData}: any = await dispatch(loadChannelsForTeam(currentTeamId, true)); + + if (myData?.channels && myData?.channelMembers) { + actions.push({ + type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS, + data: myData, + }); + } + + if (channel) { + actions.push({ + type: ChannelTypes.LEAVE_CHANNEL, + data: { + id: msg.data.channel_id, + user_id: currentUser.id, + team_id: channel.team_id, + type: channel.type, + }, + }); + } + + if (msg.data.channel_id === currentChannelId) { + // emit the event so the client can change his own state + redirectToDefaultChannel = true; + } + if (isGuest(currentUser.roles)) { + const notVisible = await notVisibleUsersActions(state); + if (notVisible.length) { + actions.push(...notVisible); + } + } + } else if (msg.data.channel_id === currentChannelId) { + const stat = await Client4.getChannelStats(currentChannelId); + actions.push({ + type: ChannelTypes.RECEIVED_CHANNEL_STATS, + data: stat, + }); + } + + dispatch(batchActions(actions, 'BATCH_WS_USER_REMOVED')); + if (redirectToDefaultChannel) { + EventEmitter.emit(General.SWITCH_TO_DEFAULT_CHANNEL, currentTeamId); + } + } catch { + // do nothing + } + + return {data: true}; + }; +} + +export function handleUserRoleUpdated(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc): Promise => { + try { + const roles = msg.data.roles.split(' '); + const data = await Client4.getRolesByNames(roles); + + dispatch({ + type: RoleTypes.RECEIVED_ROLES, + data: data.roles, + }); + } catch { + // do nothing + } + + return {data: true}; + }; +} + +export function handleUserUpdatedEvent(msg: WebSocketMessage) { + return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => { + const currentUser = getCurrentUser(getState()); + const user = msg.data.user; + + if (user.id === currentUser.id) { + if (user.update_at > currentUser.update_at) { + // Need to request me to make sure we don't override with sanitized fields from the + // websocket event + dispatch(getMe()); + } + } else { + dispatch({ + type: UserTypes.RECEIVED_PROFILES, + data: { + [user.id]: user, + }, + }); + } + return {data: true}; + }; +} \ No newline at end of file diff --git a/app/actions/websocket/websocket.test.js b/app/actions/websocket/websocket.test.js new file mode 100644 index 000000000..bd201d7e4 --- /dev/null +++ b/app/actions/websocket/websocket.test.js @@ -0,0 +1,547 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-import-assign */ + +import assert from 'assert'; +import nock from 'nock'; +import {Server, WebSocket as MockWebSocket} from 'mock-socket'; +import thunk from 'redux-thunk'; +import configureMockStore from 'redux-mock-store'; + +import {GeneralTypes, UserTypes} from '@mm-redux/action_types'; +import {notVisibleUsersActions} from '@mm-redux/actions/helpers'; +import {Client4} from '@mm-redux/client'; +import {General, Posts, RequestStatus} from '@mm-redux/constants'; + +import * as Actions from '@actions/websocket'; +import {WebsocketEvents} from '@constants'; + +import TestHelper from 'test/test_helper'; +import configureStore from 'test/test_store'; + +global.WebSocket = MockWebSocket; + +const mockConfigRequest = (config = {}) => { + nock(Client4.getBaseRoute()). + get('/config/client?format=old'). + reply(200, config); +}; + +const mockChanelsRequest = (teamId, channels = []) => { + nock(Client4.getUserRoute('me')). + get(`/teams/${teamId}/channels?include_deleted=true`). + reply(200, channels); +}; + +const mockGetKnownUsersRequest = (userIds = []) => { + nock(Client4.getBaseRoute()). + get('/users/known'). + reply(200, userIds); +}; + +const mockRolesRequest = (rolesToLoad = []) => { + nock(Client4.getRolesRoute()). + post('/names', JSON.stringify(rolesToLoad)). + reply(200, rolesToLoad); +}; + +const mockTeamMemberRequest = (tm = []) => { + nock(Client4.getUserRoute('me')). + get('/teams/members'). + reply(200, tm); +}; + +describe('Actions.Websocket', () => { + let store; + let mockServer; + beforeAll(async () => { + store = await configureStore(); + await TestHelper.initBasic(Client4); + + const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:'); + mockServer = new Server(connUrl); + return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')})); + }); + + afterAll(async () => { + Actions.close()(); + mockServer.stop(); + await TestHelper.tearDown(); + }); + + it('WebSocket Connect', () => { + const ws = store.getState().requests.general.websocket; + assert.ok(ws.status === RequestStatus.SUCCESS); + }); +}); + +describe('Actions.Websocket doReconnect', () => { + const mockStore = configureMockStore([thunk]); + const me = TestHelper.fakeUserWithId(); + const team = TestHelper.fakeTeamWithId(); + const teamMember = TestHelper.fakeTeamMember(me.id, team.id); + const channel1 = TestHelper.fakeChannelWithId(team.id); + const channel2 = TestHelper.fakeChannelWithId(team.id); + const cMember1 = TestHelper.fakeChannelMember(me.id, channel1.id); + const cMember2 = TestHelper.fakeChannelMember(me.id, channel2.id); + + const currentTeamId = team.id; + const currentUserId = me.id; + const currentChannelId = channel1.id; + + const initialState = { + entities: { + general: { + config: {}, + }, + teams: { + currentTeamId, + myMembers: { + [currentTeamId]: teamMember, + }, + teams: { + [currentTeamId]: team, + }, + }, + channels: { + currentChannelId, + channels: { + currentChannelId: channel1, + }, + }, + users: { + currentUserId, + profiles: { + [me.id]: me, + }, + }, + preferences: { + myPreferences: {}, + }, + posts: { + posts: {}, + postsInChannel: {}, + }, + }, + websocket: { + connected: false, + lastConnectAt: 0, + lastDisconnectAt: 0, + }, + }; + + beforeAll(async () => { + return TestHelper.initBasic(Client4); + }); + + beforeEach(() => { + nock(Client4.getBaseRoute()). + get('/users/me'). + reply(200, me); + + nock(Client4.getUserRoute('me')). + get('/teams'). + reply(200, [team]); + + nock(Client4.getUserRoute('me')). + get('/teams/unread'). + reply(200, [{id: team.id, msg_count: 0, mention_count: 0}]); + + nock(Client4.getBaseRoute()). + get('/users/me/preferences'). + reply(200, []); + + nock(Client4.getUserRoute('me')). + get(`/teams/${team.id}/channels/members`). + reply(200, [cMember1, cMember2]); + + nock(Client4.getChannelRoute(channel1.id)). + get(`/posts?page=0&per_page=${Posts.POST_CHUNK_SIZE}`). + reply(200, { + posts: { + post1: {id: 'post1', create_at: 0, message: 'hey'}, + }, + order: ['post1'], + }); + }); + + afterAll(async () => { + Actions.close()(); + await TestHelper.tearDown(); + }); + + it('handle doReconnect', async () => { + const state = {...initialState}; + const testStore = await mockStore(state); + const timestamp = 1000; + const expectedActions = [ + GeneralTypes.WEBSOCKET_SUCCESS, + 'BATCH_WS_RECONNECT', + 'BATCH_GET_POSTS', + ]; + + mockConfigRequest(); + mockTeamMemberRequest([teamMember]); + mockChanelsRequest(team.id, [channel1, channel2]); + + let rolesToLoad = Array.from(new Set(me.roles.split(' '). + concat(teamMember.roles.split(' ')))); + mockRolesRequest(rolesToLoad); + + rolesToLoad = Array.from(new Set(cMember1.roles.split(' '). + concat(cMember2.roles.split(' ')))); + mockRolesRequest(rolesToLoad); + + await testStore.dispatch(Actions.doReconnect(timestamp)); + await TestHelper.wait(300); + const actionTypes = testStore.getActions().map((a) => a.type); + expect(actionTypes).toEqual(expectedActions); + }); + + it('handle doReconnect after the current channel was archived or the user left it', async () => { + const state = { + ...initialState, + entities: { + ...initialState.entities, + channels: { + ...initialState.entities.channels, + currentChannelId: 'channel-3', + }, + }, + }; + const testStore = await mockStore(state); + const timestamp = 1000; + const expectedActions = [ + GeneralTypes.WEBSOCKET_SUCCESS, + 'BATCH_WS_RECONNECT', + ]; + const expectedMissingActions = [ + 'BATCH_GET_POSTS', + ]; + + mockConfigRequest(); + mockTeamMemberRequest([teamMember]); + mockChanelsRequest(team.id, [channel1, channel2]); + + let rolesToLoad = Array.from(new Set(me.roles.split(' '). + concat(teamMember.roles.split(' ')))); + mockRolesRequest(rolesToLoad); + + rolesToLoad = Array.from(new Set(cMember1.roles.split(' '). + concat(cMember2.roles.split(' ')))); + mockRolesRequest(rolesToLoad); + + await testStore.dispatch(Actions.doReconnect(timestamp)); + await TestHelper.wait(300); + + const actions = testStore.getActions().map((a) => a.type); + + expect(actions).toEqual(expect.arrayContaining(expectedActions)); + expect(actions).not.toEqual(expect.arrayContaining(expectedMissingActions)); + }); + + it('handle doReconnect after the current channel was archived and setting is on', async () => { + const archived = { + ...channel1, + delete_at: 123, + }; + const state = { + ...initialState, + channels: { + currentChannelId, + channels: { + currentChannelId: archived, + }, + }, + }; + const testStore = await mockStore(state); + const timestamp = 1000; + const expectedActions = [ + GeneralTypes.WEBSOCKET_SUCCESS, + 'BATCH_WS_RECONNECT', + ]; + + mockConfigRequest({ExperimentalViewArchivedChannels: 'true'}); + mockTeamMemberRequest([teamMember]); + mockChanelsRequest(team.id, [archived, channel2]); + + let rolesToLoad = Array.from(new Set(me.roles.split(' '). + concat(teamMember.roles.split(' ')))); + mockRolesRequest(rolesToLoad); + + rolesToLoad = Array.from(new Set(cMember1.roles.split(' '). + concat(cMember2.roles.split(' ')))); + mockRolesRequest(rolesToLoad); + + await testStore.dispatch(Actions.doReconnect(timestamp)); + await TestHelper.wait(300); + + const actions = testStore.getActions().map((a) => a.type); + expect(actions).toEqual(expect.arrayContaining(expectedActions)); + }); + + it('handle doReconnect after the current channel was archived and setting is off', async () => { + const archived = { + ...channel1, + delete_at: 123, + }; + + const state = { + ...initialState, + channels: { + currentChannelId, + channels: { + currentChannelId: archived, + }, + }, + }; + const testStore = await mockStore(state); + const timestamp = 1000; + const expectedActions = [ + GeneralTypes.WEBSOCKET_SUCCESS, + 'BATCH_WS_RECONNECT', + ]; + const expectedMissingActions = [ + 'BATCH_GET_POSTS', + ]; + + mockConfigRequest({ExperimentalViewArchivedChannels: 'false'}); + mockTeamMemberRequest([teamMember]); + mockChanelsRequest(team.id, [archived, channel2]); + + let rolesToLoad = Array.from(new Set(me.roles.split(' '). + concat(teamMember.roles.split(' ')))); + mockRolesRequest(rolesToLoad); + + rolesToLoad = Array.from(new Set(cMember1.roles.split(' '). + concat(cMember2.roles.split(' ')))); + mockRolesRequest(rolesToLoad); + + await testStore.dispatch(Actions.doReconnect(timestamp)); + await TestHelper.wait(300); + + const actions = testStore.getActions().map((a) => a.type); + expect(actions).toEqual(expectedActions); + expect(actions).not.toEqual(expect.arrayContaining(expectedMissingActions)); + }); + + it('handle doReconnect after user left current team', async () => { + const state = {...initialState}; + state.entities.teams.myMembers = {}; + const testStore = await mockStore(state); + const timestamp = 1000; + const expectedActions = [ + GeneralTypes.WEBSOCKET_SUCCESS, + 'BATCH_WS_LEAVE_TEAM', + 'BATCH_WS_RECONNECT', + ]; + const expectedMissingActions = [ + 'BATCH_GET_POSTS', + ]; + + mockConfigRequest(); + mockTeamMemberRequest([]); + mockChanelsRequest(team.id, [channel1, channel2]); + + let rolesToLoad = me.roles.split(' '); + mockRolesRequest(rolesToLoad); + + rolesToLoad = Array.from(new Set(cMember1.roles.split(' '). + concat(cMember2.roles.split(' ')))); + mockRolesRequest(rolesToLoad); + + await testStore.dispatch(Actions.doReconnect(timestamp)); + await TestHelper.wait(300); + const actions = testStore.getActions().map((a) => a.type); + expect(actions).toEqual(expectedActions); + expect(actions).not.toEqual(expect.arrayContaining(expectedMissingActions)); + }); +}); + +describe('Actions.Websocket notVisibleUsersActions', () => { + configureMockStore([thunk]); + + const me = TestHelper.fakeUserWithId(); + const user = TestHelper.fakeUserWithId(); + const user2 = TestHelper.fakeUserWithId(); + const user3 = TestHelper.fakeUserWithId(); + const user4 = TestHelper.fakeUserWithId(); + const user5 = TestHelper.fakeUserWithId(); + + it('should do nothing if the known users and the profiles list are the same', async () => { + const profiles = { + [me.id]: me, + [user.id]: user, + [user2.id]: user2, + [user3.id]: user3, + }; + Client4.serverVersion = '5.23.0'; + + const state = { + entities: { + users: { + currentUserId: me.id, + profiles, + }, + }, + }; + + mockGetKnownUsersRequest([user.id, user2.id, user3.id]); + + const actions = await notVisibleUsersActions(state); + expect(actions.length).toEqual(0); + }); + + it('should do nothing if there are known users in my memberships but not in the profiles list', async () => { + const profiles = { + [me.id]: me, + [user3.id]: user3, + }; + Client4.serverVersion = '5.23.0'; + + const state = { + entities: { + users: { + currentUserId: me.id, + profiles, + }, + }, + }; + + mockGetKnownUsersRequest([user.id, user2.id, user3.id]); + + const actions = await notVisibleUsersActions(state); + expect(actions.length).toEqual(0); + }); + + it('should remove the users if there are unknown users in the profiles list', async () => { + const profiles = { + [me.id]: me, + [user.id]: user, + [user2.id]: user2, + [user3.id]: user3, + [user4.id]: user4, + [user5.id]: user5, + }; + Client4.serverVersion = '5.23.0'; + + const state = { + entities: { + users: { + currentUserId: me.id, + profiles, + }, + }, + }; + + mockGetKnownUsersRequest([user.id, user3.id]); + + const expectedAction = [ + {type: UserTypes.PROFILE_NO_LONGER_VISIBLE, data: {user_id: user2.id}}, + {type: UserTypes.PROFILE_NO_LONGER_VISIBLE, data: {user_id: user4.id}}, + {type: UserTypes.PROFILE_NO_LONGER_VISIBLE, data: {user_id: user5.id}}, + ]; + const actions = await notVisibleUsersActions(state); + expect(actions.length).toEqual(3); + expect(actions).toEqual(expectedAction); + }); + + it('should do nothing if the server version is less than 5.23', async () => { + const profiles = { + [me.id]: me, + [user.id]: user, + [user2.id]: user2, + [user3.id]: user3, + [user4.id]: user4, + [user5.id]: user5, + }; + Client4.serverVersion = '5.22.0'; + + const state = { + entities: { + users: { + currentUserId: me.id, + profiles, + }, + }, + }; + + mockGetKnownUsersRequest([user.id, user3.id]); + + const actions = await notVisibleUsersActions(state); + expect(actions.length).toEqual(0); + }); +}); + +describe('Actions.Websocket handleUserTypingEvent', () => { + const mockStore = configureMockStore([thunk]); + + const currentUserId = 'user-id'; + const otherUserId = 'other-user-id'; + const currentChannelId = 'channel-id'; + const otherChannelId = 'other-channel-id'; + + const initialState = { + entities: { + general: { + config: {}, + }, + channels: { + currentChannelId, + channels: { + currentChannelId: { + id: currentChannelId, + name: 'channel', + }, + }, + }, + users: { + currentUserId, + profiles: { + [currentUserId]: {}, + [otherUserId]: {}, + }, + statuses: { + [currentUserId]: General.ONLINE, + [otherUserId]: General.OFFLINE, + }, + }, + preferences: { + myPreferences: {}, + }, + }, + }; + + it('dispatches actions for current channel if other user is typing', async () => { + const state = {...initialState}; + const testStore = await mockStore(state); + const msg = {broadcast: {channel_id: currentChannelId}, data: {parent_id: 'parent-id', user_id: otherUserId}}; + + nock(Client4.getUsersRoute()). + post('/status/ids', JSON.stringify([otherUserId])). + reply(200, ['away']); + + const expectedActionsTypes = [ + WebsocketEvents.TYPING, + UserTypes.RECEIVED_STATUSES, + ]; + + await testStore.dispatch(Actions.handleUserTypingEvent(msg)); + await TestHelper.wait(300); + const actionTypes = testStore.getActions().map((action) => action.type); + expect(actionTypes).toEqual(expectedActionsTypes); + }); + + it('does not dispatch actions for non current channel', async () => { + const state = {...initialState}; + const testStore = await mockStore(state); + const msg = {broadcast: {channel_id: otherChannelId}, data: {parent_id: 'parent-id', user_id: otherUserId}}; + + const expectedActionsTypes = []; + + await testStore.dispatch(Actions.handleUserTypingEvent(msg)); + const actionTypes = testStore.getActions().map((action) => action.type); + expect(actionTypes).toEqual(expectedActionsTypes); + }); +}); diff --git a/app/mm-redux/actions/helpers.ts b/app/mm-redux/actions/helpers.ts index d47d49a32..a261618a0 100644 --- a/app/mm-redux/actions/helpers.ts +++ b/app/mm-redux/actions/helpers.ts @@ -2,13 +2,19 @@ // See LICENSE.txt for license information. import {logout} from '@actions/views/user'; - +import {UserTypes} from '@mm-redux/action_types'; +import {Client4} from '@mm-redux/client'; import {Client4Error} from '@mm-redux/types/client4'; +import {getCurrentUserId, getUsers} from '@mm-redux/selectors/entities/users'; import {batchActions, Action, ActionFunc, GenericAction, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; +import {GlobalState} from '@mm-redux/types/store'; +import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {logError} from './errors'; + type ActionType = string; const HTTP_UNAUTHORIZED = 401; + export function forceLogoutIfNecessary(err: Client4Error, dispatch: DispatchFunc, getState: GetStateFunc) { const {currentUserId} = getState().entities.users; @@ -131,6 +137,29 @@ export function debounce(func: (...args: any) => unknown, wait: number, immediat }; } +export async function notVisibleUsersActions(state: GlobalState): Promise> { + if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 23)) { + return []; + } + let knownUsers: Set; + try { + const fetchResult = await Client4.getKnownUsers(); + knownUsers = new Set(fetchResult); + } catch (err) { + return []; + } + knownUsers.add(getCurrentUserId(state)); + const allUsers = Object.keys(getUsers(state)); + const usersToRemove = new Set(allUsers.filter((x) => !knownUsers.has(x))); + + const actions = []; + for (const userToRemove of usersToRemove.values()) { + actions.push({type: UserTypes.PROFILE_NO_LONGER_VISIBLE, data: {user_id: userToRemove}}); + } + + return actions; +} + export class FormattedError extends Error { intl: { id: string; diff --git a/app/mm-redux/types/websocket.ts b/app/mm-redux/types/websocket.ts new file mode 100644 index 000000000..3b2e75368 --- /dev/null +++ b/app/mm-redux/types/websocket.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Dictionary} from '@mm-redux/types/utilities'; + +export type WebsocketBroadcast = { + omit_users: Dictionary; + user_id: string; + channel_id: string; + team_id: string; +} + +export type WebSocketMessage = { + event: string; + data: any; + broadcast: WebsocketBroadcast; + seq: number; +} \ No newline at end of file