forked from Ivasoft/mattermost-mobile
Compare commits
40 Commits
match
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
283d86c35a | ||
|
|
e799a4f363 | ||
|
|
6a35352d10 | ||
|
|
6d7b594e5f | ||
|
|
c3ce1c1f0d | ||
|
|
20d7d87464 | ||
|
|
72c9414993 | ||
|
|
12dbcfe627 | ||
|
|
5a019e7447 | ||
|
|
d6147d289b | ||
|
|
66d730387e | ||
|
|
798bb45782 | ||
|
|
46d7a17d4a | ||
|
|
c6978c858a | ||
|
|
bcec21d264 | ||
|
|
235f26e7fd | ||
|
|
8c3184080d | ||
|
|
4992052fb0 | ||
|
|
e1ec0fdf94 | ||
|
|
c8854ba51e | ||
|
|
838a52221f | ||
|
|
5ee6142142 | ||
|
|
f7848c9259 | ||
|
|
977b385dc7 | ||
|
|
3e263e9119 | ||
|
|
012bc45800 | ||
|
|
9b6b3d9bbc | ||
|
|
a6dd6e65ff | ||
|
|
be2211a8cc | ||
|
|
f19701d704 | ||
|
|
2e3ff53988 | ||
|
|
a9a23f706a | ||
|
|
59ed19cebd | ||
|
|
4dc929579b | ||
|
|
1326fb53f2 | ||
|
|
aafc68a0e6 | ||
|
|
01c796e441 | ||
|
|
3f80957240 | ||
|
|
cfdac2a3f9 | ||
|
|
fc815adaeb |
74
NOTICE.txt
74
NOTICE.txt
@@ -113,39 +113,6 @@ SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## @react-native-community/cookies
|
||||
|
||||
This product contains '@react-native-community/cookies' by React Native Community.
|
||||
|
||||
Cookie Manager for React Native
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/react-native-community/cookies
|
||||
|
||||
* LICENSE: MIT License
|
||||
|
||||
Copyright (c) 2020 React Native Community
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## @react-native-community/masked-view
|
||||
|
||||
This product contains '@react-native-community/masked-view' by React Native Community.
|
||||
@@ -1560,6 +1527,41 @@ THE SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## react-native-cookies
|
||||
|
||||
This product contains a modified version of 'react-native-cookies' by Joseph P. Ferraro.
|
||||
|
||||
Cookie manager for react native.
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/joeferraro/react-native-cookies
|
||||
|
||||
* LICENSE: MIT
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 shimo
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## react-native-device-info
|
||||
|
||||
This product contains a modified version of 'react-native-device-info' by Rebecca Hughes.
|
||||
@@ -1920,12 +1922,12 @@ SOFTWARE.
|
||||
|
||||
## react-native-keyboard-aware-scroll-view
|
||||
|
||||
This product contains 'react-native-keyboard-aware-scroll-view' by Alvaro Medina Ballester.
|
||||
This product contains a modified version of 'react-native-keyboard-aware-scroll-view' by APSL.
|
||||
|
||||
A React Native ScrollView component that resizes when the keyboard appears.
|
||||
A ScrollView component that handles keyboard appearance and automatically scrolls to focused TextInput.
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/APSL/react-native-keyboard-aware-scroll-view#readme
|
||||
* https://github.com/APSL/react-native-keyboard-aware-scroll-view
|
||||
|
||||
* LICENSE: MIT
|
||||
|
||||
|
||||
@@ -133,8 +133,8 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
|
||||
versionCode 311
|
||||
versionName "1.33.1"
|
||||
versionCode 320
|
||||
versionName "1.34.1"
|
||||
multiDexEnabled = true
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a','arm64-v8a','x86','x86_64'
|
||||
|
||||
@@ -532,7 +532,12 @@ public class CustomPushNotification extends PushNotification {
|
||||
}
|
||||
|
||||
private String removeSenderNameFromMessage(String message, String senderName) {
|
||||
return message.replaceFirst(senderName, "").replaceFirst(": ", "").trim();
|
||||
Integer index = message.indexOf(senderName);
|
||||
if (index == 0) {
|
||||
message = message.substring(senderName.length());
|
||||
}
|
||||
|
||||
return message.replaceFirst(": ", "").trim();
|
||||
}
|
||||
|
||||
private void notificationReceiptDelivery(String ackId, String postId, String type, boolean isIdLoaded, ResolvePromise promise) {
|
||||
|
||||
@@ -7,9 +7,11 @@ import merge from 'deepmerge';
|
||||
|
||||
import {Preferences} from '@mm-redux/constants';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import EventEmmiter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import Store from '@store/store';
|
||||
import {NavigationTypes} from '@constants';
|
||||
|
||||
const CHANNEL_SCREEN = 'Channel';
|
||||
|
||||
@@ -223,6 +225,13 @@ export async function popToRoot() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissAllModalsAndPopToRoot() {
|
||||
await dismissAllModals();
|
||||
await popToRoot();
|
||||
|
||||
EventEmmiter.emit(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT);
|
||||
}
|
||||
|
||||
export function showModal(name, title, passProps = {}, options = {}) {
|
||||
const theme = getThemeFromState();
|
||||
const defaultOptions = {
|
||||
|
||||
@@ -7,11 +7,14 @@ import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import merge from 'deepmerge';
|
||||
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
import * as NavigationActions from '@actions/navigation';
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import intitialState from '@store/initial_state';
|
||||
import Store from '@store/store';
|
||||
import {NavigationTypes} from '@constants';
|
||||
|
||||
jest.unmock('@actions/navigation');
|
||||
jest.mock('@store/ephemeral_store', () => ({
|
||||
@@ -480,4 +483,15 @@ describe('@actions/navigation', () => {
|
||||
await NavigationActions.dismissOverlay(topComponentId);
|
||||
expect(dismissOverlay).toHaveBeenCalledWith(topComponentId);
|
||||
});
|
||||
|
||||
test('dismissAllModalsAndPopToRoot should call Navigation.dismissAllModals, Navigation.popToRoot, and emit event', async () => {
|
||||
const dismissAllModals = jest.spyOn(Navigation, 'dismissAllModals');
|
||||
const popToRoot = jest.spyOn(Navigation, 'popToRoot');
|
||||
EventEmitter.emit = jest.fn();
|
||||
|
||||
await NavigationActions.dismissAllModalsAndPopToRoot();
|
||||
expect(dismissAllModals).toHaveBeenCalled();
|
||||
expect(popToRoot).toHaveBeenCalledWith(topComponentId);
|
||||
expect(EventEmitter.emit).toHaveBeenCalledWith(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT);
|
||||
});
|
||||
});
|
||||
@@ -586,6 +586,7 @@ function loadGroupData() {
|
||||
const state = getState();
|
||||
const actions = [];
|
||||
const team = getCurrentTeam(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const serverVersion = state.entities.general.serverVersion;
|
||||
const license = getLicense(state);
|
||||
const hasLicense = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true';
|
||||
@@ -615,7 +616,7 @@ function loadGroupData() {
|
||||
} else {
|
||||
const [getAllGroupsAssociatedToChannelsInTeam, getGroups] = await Promise.all([ //eslint-disable-line no-await-in-loop
|
||||
Client4.getAllGroupsAssociatedToChannelsInTeam(team.id, true),
|
||||
Client4.getGroups(true),
|
||||
Client4.getGroups(true, 0, 0),
|
||||
]);
|
||||
|
||||
if (getAllGroupsAssociatedToChannelsInTeam.groups) {
|
||||
@@ -632,10 +633,25 @@ function loadGroupData() {
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
} catch (err) {
|
||||
return {error: err};
|
||||
if (i === MAX_RETRIES) {
|
||||
return {error: err};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const myGroups = await Client4.getGroupsByUserId(currentUserId);
|
||||
if (myGroups.length) {
|
||||
actions.push({
|
||||
type: GroupTypes.RECEIVED_MY_GROUPS,
|
||||
data: myGroups,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
if (actions.length) {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import {NavigationTypes, ViewTypes} from '@constants';
|
||||
import {recordTime} from '@init/analytics.ts';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
import {ChannelTypes, GeneralTypes, TeamTypes} from '@mm-redux/action_types';
|
||||
import {fetchMyChannelsAndMembers} from '@mm-redux/actions/channels';
|
||||
import {fetchMyChannelsAndMembers, getChannelAndMyMember} from '@mm-redux/actions/channels';
|
||||
import {getDataRetentionPolicy} from '@mm-redux/actions/general';
|
||||
import {receivedNewPost} from '@mm-redux/actions/posts';
|
||||
import {getMyTeams, getMyTeamMembers} from '@mm-redux/actions/teams';
|
||||
@@ -102,6 +102,8 @@ export function loadFromPushNotification(notification) {
|
||||
export function handleSelectTeamAndChannel(teamId, channelId) {
|
||||
return async (dispatch, getState) => {
|
||||
const dt = Date.now();
|
||||
await dispatch(getChannelAndMyMember(channelId));
|
||||
|
||||
const state = getState();
|
||||
const {channels, currentChannelId, myMembers} = state.entities.channels;
|
||||
const {currentTeamId} = state.entities.teams;
|
||||
@@ -180,7 +182,7 @@ export function recordLoadTime(screenName, category) {
|
||||
return async (dispatch, getState) => {
|
||||
const {currentUserId} = getState().entities.users;
|
||||
|
||||
recordTime(screenName, category, currentUserId);
|
||||
analytics.recordTime(screenName, category, currentUserId);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities
|
||||
import {setAppCredentials} from 'app/init/credentials';
|
||||
import {setCSRFFromCookie} from '@utils/security';
|
||||
import {getDeviceTimezone} from '@utils/timezone';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
|
||||
@@ -39,8 +40,8 @@ export function completeLogin(user, deviceToken) {
|
||||
}
|
||||
|
||||
// Data retention
|
||||
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
|
||||
license.IsLicensed === 'true' && license.DataRetention === 'true') {
|
||||
if (config?.DataRetentionEnableMessageDeletion && config?.DataRetentionEnableMessageDeletion === 'true' &&
|
||||
license?.IsLicensed === 'true' && license?.DataRetention === 'true') {
|
||||
dispatch(getDataRetentionPolicy());
|
||||
} else {
|
||||
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
|
||||
@@ -96,8 +97,8 @@ export function loadMe(user, deviceToken, skipDispatch = false) {
|
||||
}
|
||||
|
||||
try {
|
||||
Client4.setUserId(data.user.id);
|
||||
Client4.setUserRoles(data.user.roles);
|
||||
analytics.setUserId(data.user.id);
|
||||
analytics.setUserRoles(data.user.roles);
|
||||
|
||||
// Execute all other requests in parallel
|
||||
const teamsRequest = Client4.getMyTeams();
|
||||
@@ -182,14 +183,10 @@ export function login(loginId, password, mfaToken, ldapOnly = false) {
|
||||
};
|
||||
}
|
||||
|
||||
export function ssoLogin(token) {
|
||||
export function ssoLogin() {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const deviceToken = state.entities?.general?.deviceToken;
|
||||
|
||||
Client4.setToken(token);
|
||||
await setCSRFFromCookie(Client4.getUrl());
|
||||
|
||||
const result = await dispatch(loadMe());
|
||||
|
||||
if (!result.error) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
179
app/actions/websocket/channels.test.js
Normal file
179
app/actions/websocket/channels.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
238
app/actions/websocket/channels.ts
Normal file
238
app/actions/websocket/channels.ts
Normal file
@@ -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<ActionResult> => {
|
||||
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<GenericAction> = [{
|
||||
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<ActionResult> => {
|
||||
try {
|
||||
const channelMember = JSON.parse(msg.data.channelMember);
|
||||
const rolesToLoad = channelMember.roles.split(' ');
|
||||
const actions: Array<GenericAction> = [{
|
||||
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<ActionResult> => {
|
||||
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<ActionResult> => {
|
||||
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<GenericAction> = [{
|
||||
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<ActionResult> => {
|
||||
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<ActionResult> => {
|
||||
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<ActionResult> => {
|
||||
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};
|
||||
};
|
||||
}
|
||||
|
||||
60
app/actions/websocket/general.test.js
Normal file
60
app/actions/websocket/general.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
27
app/actions/websocket/general.ts
Normal file
27
app/actions/websocket/general.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
23
app/actions/websocket/groups.ts
Normal file
23
app/actions/websocket/groups.ts
Normal file
@@ -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};
|
||||
};
|
||||
}
|
||||
403
app/actions/websocket/index.ts
Normal file
403
app/actions/websocket/index.ts
Normal file
@@ -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<ActionResult> => {
|
||||
const state = getState();
|
||||
const {lastDisconnectAt} = state.websocket;
|
||||
const actions: Array<GenericAction> = [{
|
||||
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<ActionResult> => {
|
||||
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<GenericAction> = [];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
47
app/actions/websocket/integrations.test.js
Normal file
47
app/actions/websocket/integrations.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
14
app/actions/websocket/integrations.ts
Normal file
14
app/actions/websocket/integrations.ts
Normal file
@@ -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};
|
||||
};
|
||||
}
|
||||
265
app/actions/websocket/posts.test.js
Normal file
265
app/actions/websocket/posts.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
209
app/actions/websocket/posts.ts
Normal file
209
app/actions/websocket/posts.ts
Normal file
@@ -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<ActionResult> => {
|
||||
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<GenericAction> = [];
|
||||
|
||||
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<ActionResult> => {
|
||||
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};
|
||||
};
|
||||
}
|
||||
60
app/actions/websocket/preferences.ts
Normal file
60
app/actions/websocket/preferences.ts
Normal file
@@ -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<ActionResult> => {
|
||||
const preference = JSON.parse(msg.data.preference);
|
||||
const actions: Array<GenericAction> = [{
|
||||
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<ActionResult> => {
|
||||
const preferences: PreferenceType[] = JSON.parse(msg.data.preferences);
|
||||
const posts = getAllPosts(getState());
|
||||
const actions: Array<GenericAction> = [{
|
||||
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};
|
||||
}
|
||||
60
app/actions/websocket/reactions.test.js
Normal file
60
app/actions/websocket/reactions.test.js
Normal file
@@ -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]);
|
||||
});
|
||||
});
|
||||
41
app/actions/websocket/reactions.ts
Normal file
41
app/actions/websocket/reactions.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
33
app/actions/websocket/roles.ts
Normal file
33
app/actions/websocket/roles.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
106
app/actions/websocket/teams.test.js
Normal file
106
app/actions/websocket/teams.test.js
Normal file
@@ -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]);
|
||||
});
|
||||
});
|
||||
106
app/actions/websocket/teams.ts
Normal file
106
app/actions/websocket/teams.ts
Normal file
@@ -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<WebSocketMessage>) {
|
||||
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
|
||||
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<GenericAction> = [{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<ActionResult> => {
|
||||
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<string>();
|
||||
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};
|
||||
};
|
||||
}
|
||||
94
app/actions/websocket/users.test.js
Normal file
94
app/actions/websocket/users.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
204
app/actions/websocket/users.ts
Normal file
204
app/actions/websocket/users.ts
Normal file
@@ -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<ActionResult> => {
|
||||
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<GenericAction> = [{
|
||||
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<ActionResult> => {
|
||||
try {
|
||||
const state = getState();
|
||||
const channels = getAllChannels(state);
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
const currentTeamId = getCurrentTeamId(state);
|
||||
const currentUser = getCurrentUser(state);
|
||||
const actions: Array<GenericAction> = [];
|
||||
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<ActionResult> => {
|
||||
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};
|
||||
};
|
||||
}
|
||||
547
app/actions/websocket/websocket.test.js
Normal file
547
app/actions/websocket/websocket.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ export default class AtMention extends React.PureComponent {
|
||||
teammateNameDisplay: PropTypes.string,
|
||||
theme: PropTypes.object.isRequired,
|
||||
usersByUsername: PropTypes.object.isRequired,
|
||||
groupsByName: PropTypes.object,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
@@ -93,6 +94,12 @@ export default class AtMention extends React.PureComponent {
|
||||
};
|
||||
}
|
||||
|
||||
getGroupFromMentionName() {
|
||||
const {groupsByName, mentionName} = this.props;
|
||||
const mentionNameTrimmed = mentionName.toLowerCase().replace(/[._-]*$/, '');
|
||||
return groupsByName?.[mentionNameTrimmed] || {};
|
||||
}
|
||||
|
||||
handleLongPress = async () => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
|
||||
@@ -134,13 +141,28 @@ export default class AtMention extends React.PureComponent {
|
||||
render() {
|
||||
const {isSearchResult, mentionName, mentionStyle, onPostPress, teammateNameDisplay, textStyle, mentionKeys} = this.props;
|
||||
const {user} = this.state;
|
||||
let highlighted;
|
||||
|
||||
if (!user.username) {
|
||||
const group = this.getGroupFromMentionName();
|
||||
if (group.allow_reference) {
|
||||
highlighted = mentionKeys.some((item) => item.key === group.name);
|
||||
return (
|
||||
<Text
|
||||
style={textStyle}
|
||||
>
|
||||
<Text style={highlighted ? null : mentionStyle}>
|
||||
{`@${group.name}`}
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text style={textStyle}>{'@' + mentionName}</Text>;
|
||||
}
|
||||
|
||||
const suffix = this.props.mentionName.substring(user.username.length);
|
||||
const highlighted = mentionKeys.some((item) => item.key === user.username);
|
||||
highlighted = mentionKeys.some((item) => item.key === user.username);
|
||||
|
||||
return (
|
||||
<Text
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {getUsersByUsername, getCurrentUserMentionKeys} from '@mm-redux/selectors/entities/users';
|
||||
import {getUsersByUsername} from '@mm-redux/selectors/entities/users';
|
||||
|
||||
import {getAllUserMentionKeys} from '@mm-redux/selectors/entities/search';
|
||||
|
||||
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
|
||||
import {getGroupsByName} from '@mm-redux/selectors/entities/groups';
|
||||
|
||||
import AtMention from './at_mention';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
function mapStateToProps(state, ownProps) {
|
||||
return {
|
||||
theme: getTheme(state),
|
||||
usersByUsername: getUsersByUsername(state),
|
||||
mentionKeys: getCurrentUserMentionKeys(state),
|
||||
mentionKeys: ownProps.mentionKeys || getAllUserMentionKeys(state),
|
||||
teammateNameDisplay: getTeammateNameDisplaySetting(state),
|
||||
groupsByName: getGroupsByName(state),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export default class AtMention extends PureComponent {
|
||||
|
||||
// Not invoked, render nothing.
|
||||
if (matchTerm === null) {
|
||||
this.props.onResultCountChange(0);
|
||||
this.setState({
|
||||
mentionComplete: false,
|
||||
sections: [],
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Keyboard,
|
||||
Platform,
|
||||
View,
|
||||
ViewPropTypes,
|
||||
} from 'react-native';
|
||||
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
@@ -37,6 +38,7 @@ export default class Autocomplete extends PureComponent {
|
||||
nestedScrollEnabled: PropTypes.bool,
|
||||
expandDown: PropTypes.bool,
|
||||
onVisible: PropTypes.func,
|
||||
style: ViewPropTypes.style,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -194,6 +196,10 @@ export default class Autocomplete extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.style) {
|
||||
containerStyles.push(this.props.style);
|
||||
}
|
||||
|
||||
const maxListHeight = this.maxListHeight();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,45 +1,6 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/autocomplete/emoji_suggestion should match snapshot 1`] = `
|
||||
<FlatList
|
||||
ItemSeparatorComponent={[Function]}
|
||||
data={Array []}
|
||||
disableVirtualization={false}
|
||||
extraData={
|
||||
Object {
|
||||
"active": false,
|
||||
"dataSource": Array [],
|
||||
}
|
||||
}
|
||||
horizontal={false}
|
||||
initialListSize={10}
|
||||
initialNumToRender={10}
|
||||
keyExtractor={[Function]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
maxToRenderPerBatch={10}
|
||||
nestedScrollEnabled={false}
|
||||
numColumns={1}
|
||||
onEndReachedThreshold={2}
|
||||
pageSize={10}
|
||||
removeClippedSubviews={false}
|
||||
renderItem={[Function]}
|
||||
scrollEventThrottle={50}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"backgroundColor": "#ffffff",
|
||||
"flex": 1,
|
||||
},
|
||||
Object {
|
||||
"height": 0,
|
||||
"maxHeight": undefined,
|
||||
},
|
||||
]
|
||||
}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
/>
|
||||
`;
|
||||
exports[`components/autocomplete/emoji_suggestion should match snapshot 1`] = `null`;
|
||||
|
||||
exports[`components/autocomplete/emoji_suggestion should match snapshot 2`] = `
|
||||
<FlatList
|
||||
@@ -3083,10 +3044,8 @@ exports[`components/autocomplete/emoji_suggestion should match snapshot 2`] = `
|
||||
Array [
|
||||
Object {
|
||||
"backgroundColor": "#ffffff",
|
||||
"flex": 1,
|
||||
},
|
||||
Object {
|
||||
"height": undefined,
|
||||
"maxHeight": undefined,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -65,7 +65,6 @@ export default class EmojiSuggestion extends PureComponent {
|
||||
super(props);
|
||||
|
||||
this.matchTerm = '';
|
||||
this.listRef = React.createRef();
|
||||
fuse = new Fuse(props.emojis, FUSE_OPTIONS);
|
||||
}
|
||||
|
||||
@@ -216,23 +215,16 @@ export default class EmojiSuggestion extends PureComponent {
|
||||
render() {
|
||||
const {maxListHeight, theme, nestedScrollEnabled} = this.props;
|
||||
|
||||
let height;
|
||||
if (!this.state.active) {
|
||||
// If we are not in an active state set a height of 0 so nothing is rendered
|
||||
// and other components are not blocked.
|
||||
height = 0;
|
||||
if (this.listRef.current) {
|
||||
this.listRef.current.scrollToOffset({offset: 0});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleFromTheme(theme);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
ref={this.listRef}
|
||||
keyboardShouldPersistTaps='always'
|
||||
style={[style.listView, {maxHeight: maxListHeight, height}]}
|
||||
style={[style.listView, {maxHeight: maxListHeight}]}
|
||||
extraData={this.state}
|
||||
data={this.state.dataSource}
|
||||
keyExtractor={this.keyExtractor}
|
||||
@@ -260,7 +252,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
listView: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
row: {
|
||||
|
||||
@@ -5,8 +5,8 @@ import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
import {createSelector} from 'reselect';
|
||||
|
||||
import {getAutocompleteCommands} from '@mm-redux/actions/integrations';
|
||||
import {getAutocompleteCommandsList} from '@mm-redux/selectors/entities/integrations';
|
||||
import {getAutocompleteCommands, getCommandAutocompleteSuggestions} from '@mm-redux/actions/integrations';
|
||||
import {getAutocompleteCommandsList, getCommandAutocompleteSuggestionsList} from '@mm-redux/selectors/entities/integrations';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {isLandscape} from 'app/selectors/device';
|
||||
@@ -32,6 +32,7 @@ function mapStateToProps(state) {
|
||||
currentTeamId: getCurrentTeamId(state),
|
||||
theme: getTheme(state),
|
||||
isLandscape: isLandscape(state),
|
||||
suggestions: getCommandAutocompleteSuggestionsList(state),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,8 +40,9 @@ function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
getAutocompleteCommands,
|
||||
getCommandAutocompleteSuggestions,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SlashSuggestion);
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SlashSuggestion);
|
||||
|
||||
@@ -12,14 +12,17 @@ import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divide
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
|
||||
import SlashSuggestionItem from './slash_suggestion_item';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
|
||||
const SLASH_REGEX = /(^\/)([a-zA-Z-]*)$/;
|
||||
const TIME_BEFORE_NEXT_COMMAND_REQUEST = 1000 * 60 * 5;
|
||||
|
||||
export default class SlashSuggestion extends PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
getAutocompleteCommands: PropTypes.func.isRequired,
|
||||
getCommandAutocompleteSuggestions: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
currentTeamId: PropTypes.string.isRequired,
|
||||
commands: PropTypes.array,
|
||||
@@ -31,6 +34,9 @@ export default class SlashSuggestion extends PureComponent {
|
||||
value: PropTypes.string,
|
||||
isLandscape: PropTypes.bool.isRequired,
|
||||
nestedScrollEnabled: PropTypes.bool,
|
||||
suggestions: PropTypes.array,
|
||||
rootId: PropTypes.string,
|
||||
channelId: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -40,13 +46,13 @@ export default class SlashSuggestion extends PureComponent {
|
||||
|
||||
state = {
|
||||
active: false,
|
||||
suggestionComplete: false,
|
||||
dataSource: [],
|
||||
lastCommandRequest: 0,
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.isSearch) {
|
||||
if ((nextProps.value === this.props.value && nextProps.suggestions === this.props.suggestions && nextProps.commands === this.props.commands) ||
|
||||
nextProps.isSearch || nextProps.value.startsWith('//') || !nextProps.channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,49 +61,73 @@ export default class SlashSuggestion extends PureComponent {
|
||||
commands: nextCommands,
|
||||
currentTeamId: nextTeamId,
|
||||
value: nextValue,
|
||||
suggestions: nextSuggestions,
|
||||
} = nextProps;
|
||||
|
||||
if (currentTeamId !== nextTeamId) {
|
||||
this.setState({
|
||||
lastCommandRequest: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const match = nextValue.match(SLASH_REGEX);
|
||||
|
||||
if (!match || this.state.suggestionComplete) {
|
||||
if (nextValue[0] !== '/') {
|
||||
this.setState({
|
||||
active: false,
|
||||
matchTerm: null,
|
||||
suggestionComplete: false,
|
||||
});
|
||||
this.props.onResultCountChange(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataIsStale = Date.now() - this.state.lastCommandRequest > TIME_BEFORE_NEXT_COMMAND_REQUEST;
|
||||
if (nextValue.indexOf(' ') === -1) { // return suggestions for a top level cached commands
|
||||
if (currentTeamId !== nextTeamId) {
|
||||
this.setState({
|
||||
lastCommandRequest: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if ((!nextCommands.length || dataIsStale)) {
|
||||
this.props.actions.getAutocompleteCommands(nextProps.currentTeamId);
|
||||
const dataIsStale = Date.now() - this.state.lastCommandRequest > TIME_BEFORE_NEXT_COMMAND_REQUEST;
|
||||
|
||||
if ((!nextCommands.length || dataIsStale)) {
|
||||
this.props.actions.getAutocompleteCommands(nextProps.currentTeamId);
|
||||
this.setState({
|
||||
lastCommandRequest: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const matches = this.filterSlashSuggestions(nextValue.substring(1), nextCommands);
|
||||
this.updateSuggestions(matches);
|
||||
} else if (isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
|
||||
if (nextSuggestions === this.props.suggestions) {
|
||||
const args = {
|
||||
channel_id: this.props.channelId,
|
||||
...(this.props.rootId && {root_id: this.props.rootId, parent_id: this.props.rootId}),
|
||||
};
|
||||
this.props.actions.getCommandAutocompleteSuggestions(nextValue, nextTeamId, args);
|
||||
} else {
|
||||
const matches = [];
|
||||
nextSuggestions.forEach((sug) => {
|
||||
if (!this.contains(matches, '/' + sug.Complete)) {
|
||||
matches.push({
|
||||
Complete: sug.Complete,
|
||||
Suggestion: sug.Suggestion,
|
||||
Hint: sug.Hint,
|
||||
Description: sug.Description,
|
||||
});
|
||||
}
|
||||
});
|
||||
this.updateSuggestions(matches);
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
lastCommandRequest: Date.now(),
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const matchTerm = match[2];
|
||||
|
||||
const data = this.filterSlashSuggestions(matchTerm, nextCommands);
|
||||
|
||||
updateSuggestions = (matches) => {
|
||||
this.setState({
|
||||
active: data.length,
|
||||
dataSource: data,
|
||||
active: matches.length,
|
||||
dataSource: matches,
|
||||
});
|
||||
|
||||
this.props.onResultCountChange(data.length);
|
||||
this.props.onResultCountChange(matches.length);
|
||||
}
|
||||
|
||||
filterSlashSuggestions = (matchTerm, commands) => {
|
||||
return commands.filter((command) => {
|
||||
const data = commands.filter((command) => {
|
||||
if (!command.auto_complete) {
|
||||
return false;
|
||||
} else if (!matchTerm) {
|
||||
@@ -106,10 +136,23 @@ export default class SlashSuggestion extends PureComponent {
|
||||
|
||||
return command.display_name.startsWith(matchTerm) || command.trigger.startsWith(matchTerm);
|
||||
});
|
||||
return data.map((item) => {
|
||||
return {
|
||||
Complete: item.trigger,
|
||||
Suggestion: '/' + item.trigger,
|
||||
Hint: item.auto_complete_hint,
|
||||
Description: item.auto_complete_desc,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
contains = (matches, complete) => {
|
||||
return matches.findIndex((match) => match.complete === complete) !== -1;
|
||||
}
|
||||
|
||||
completeSuggestion = (command) => {
|
||||
const {onChangeText} = this.props;
|
||||
analytics.trackCommand('complete_suggestion', `/${command} `);
|
||||
|
||||
// We are going to set a double / on iOS to prevent the auto correct from taking over and replacing it
|
||||
// with the wrong value, this is a hack but I could not found another way to solve it
|
||||
@@ -128,21 +171,23 @@ export default class SlashSuggestion extends PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
active: false,
|
||||
suggestionComplete: true,
|
||||
});
|
||||
if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
|
||||
this.setState({
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
keyExtractor = (item) => item.id || item.trigger;
|
||||
keyExtractor = (item) => item.id || item.Suggestion;
|
||||
|
||||
renderItem = ({item}) => (
|
||||
<SlashSuggestionItem
|
||||
description={item.auto_complete_desc}
|
||||
hint={item.auto_complete_hint}
|
||||
description={item.Description}
|
||||
hint={item.Hint}
|
||||
onPress={this.completeSuggestion}
|
||||
theme={this.props.theme}
|
||||
trigger={item.trigger}
|
||||
suggestion={item.Suggestion}
|
||||
complete={item.Complete}
|
||||
isLandscape={this.props.isLandscape}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -15,13 +15,14 @@ export default class SlashSuggestionItem extends PureComponent {
|
||||
hint: PropTypes.string,
|
||||
onPress: PropTypes.func.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
trigger: PropTypes.string,
|
||||
suggestion: PropTypes.string,
|
||||
complete: PropTypes.string,
|
||||
isLandscape: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
completeSuggestion = () => {
|
||||
const {onPress, trigger} = this.props;
|
||||
onPress(trigger);
|
||||
const {onPress, complete} = this.props;
|
||||
onPress(complete);
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -29,7 +30,7 @@ export default class SlashSuggestionItem extends PureComponent {
|
||||
description,
|
||||
hint,
|
||||
theme,
|
||||
trigger,
|
||||
suggestion,
|
||||
isLandscape,
|
||||
} = this.props;
|
||||
|
||||
@@ -41,7 +42,7 @@ export default class SlashSuggestionItem extends PureComponent {
|
||||
style={[style.row, padding(isLandscape)]}
|
||||
type={'opacity'}
|
||||
>
|
||||
<Text style={style.suggestionName}>{`/${trigger} ${hint}`}</Text>
|
||||
<Text style={style.suggestionName}>{`${suggestion} ${hint}`}</Text>
|
||||
<Text style={style.suggestionDescription}>{description}</Text>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
|
||||
@@ -283,14 +283,6 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
value="header"
|
||||
/>
|
||||
</View>
|
||||
<Connect(Autocomplete)
|
||||
cursorPosition={6}
|
||||
expandDown={true}
|
||||
maxHeight={200}
|
||||
nestedScrollEnabled={true}
|
||||
onChangeText={[Function]}
|
||||
value="header"
|
||||
/>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
@@ -319,5 +311,25 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAwareScrollView>
|
||||
<KeyboardTrackingView
|
||||
style={
|
||||
Object {
|
||||
"justifyContent": "flex-end",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Connect(Autocomplete)
|
||||
cursorPosition={6}
|
||||
maxHeight={200}
|
||||
nestedScrollEnabled={true}
|
||||
onChangeText={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"position": undefined,
|
||||
}
|
||||
}
|
||||
value="header"
|
||||
/>
|
||||
</KeyboardTrackingView>
|
||||
</React.Fragment>
|
||||
`;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
|
||||
import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view';
|
||||
|
||||
import {General} from '@mm-redux/constants';
|
||||
|
||||
@@ -228,7 +229,7 @@ export default class EditChannelInfo extends PureComponent {
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<StatusBar/>
|
||||
<KeyboardAwareScrollView
|
||||
ref={this.scroll}
|
||||
@@ -343,14 +344,6 @@ export default class EditChannelInfo extends PureComponent {
|
||||
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
|
||||
/>
|
||||
</View>
|
||||
<Autocomplete
|
||||
cursorPosition={header.length}
|
||||
maxHeight={200}
|
||||
onChangeText={this.onHeaderChangeText}
|
||||
value={header}
|
||||
nestedScrollEnabled={true}
|
||||
expandDown={true}
|
||||
/>
|
||||
<View style={style.headerHelpText}>
|
||||
<FormattedText
|
||||
style={[style.helpText, padding(isLandscape)]}
|
||||
@@ -361,13 +354,29 @@ export default class EditChannelInfo extends PureComponent {
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</KeyboardAwareScrollView>
|
||||
</React.Fragment>
|
||||
<KeyboardTrackingView style={style.autocompleteContainer}>
|
||||
<Autocomplete
|
||||
cursorPosition={header.length}
|
||||
maxHeight={200}
|
||||
onChangeText={this.onHeaderChangeText}
|
||||
value={header}
|
||||
nestedScrollEnabled={true}
|
||||
style={style.autocomplete}
|
||||
/>
|
||||
</KeyboardTrackingView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
autocomplete: {
|
||||
position: undefined,
|
||||
},
|
||||
autocompleteContainer: {
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -49,7 +49,7 @@ exports[`Markdown should match with disableAtChannelMentionHighlight 1`] = `
|
||||
first={true}
|
||||
last={true}
|
||||
literal={null}
|
||||
nodeKey="6"
|
||||
nodeKey="8"
|
||||
>
|
||||
<Unknown
|
||||
context={
|
||||
@@ -64,21 +64,23 @@ exports[`Markdown should match with disableAtChannelMentionHighlight 1`] = `
|
||||
context={
|
||||
Array [
|
||||
"paragraph",
|
||||
"mention_highlight",
|
||||
]
|
||||
}
|
||||
literal={null}
|
||||
mentionName="all"
|
||||
nodeKey="5"
|
||||
nodeKey="6"
|
||||
>
|
||||
<Unknown
|
||||
context={
|
||||
Array [
|
||||
"paragraph",
|
||||
"mention_highlight",
|
||||
"at_mention",
|
||||
]
|
||||
}
|
||||
literal="@all"
|
||||
nodeKey="4"
|
||||
nodeKey="5"
|
||||
/>
|
||||
</Unknown>
|
||||
</Unknown>
|
||||
|
||||
@@ -5,16 +5,16 @@ import {connect} from 'react-redux';
|
||||
|
||||
import {getAutolinkedUrlSchemes, getConfig} from '@mm-redux/selectors/entities/general';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getCurrentUserMentionKeys} from '@mm-redux/selectors/entities/users';
|
||||
import {getAllUserMentionKeys} from '@mm-redux/selectors/entities/search';
|
||||
|
||||
import Markdown from './markdown';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const {MinimumHashtagLength} = getConfig(state);
|
||||
|
||||
return {
|
||||
autolinkedUrlSchemes: getAutolinkedUrlSchemes(state),
|
||||
mentionKeys: getCurrentUserMentionKeys(state),
|
||||
mentionKeys: ownProps.mentionKeys || getAllUserMentionKeys(state),
|
||||
minimumHashtagLength: MinimumHashtagLength ? parseInt(MinimumHashtagLength, 10) : 3,
|
||||
theme: getTheme(state),
|
||||
};
|
||||
|
||||
@@ -92,14 +92,6 @@ export default class Markdown extends PureComponent {
|
||||
return !scheme || this.props.autolinkedUrlSchemes.indexOf(scheme) !== -1;
|
||||
};
|
||||
|
||||
getMentionKeys = () => {
|
||||
const mentionKeys = this.props.mentionKeys;
|
||||
if (this.props.disableAtChannelMentionHighlight) {
|
||||
return mentionKeys.filter((mention) => !['@all', '@channel', '@here'].includes(mention.key));
|
||||
}
|
||||
return mentionKeys;
|
||||
}
|
||||
|
||||
createRenderer = () => {
|
||||
return new Renderer({
|
||||
renderers: {
|
||||
@@ -223,6 +215,7 @@ export default class Markdown extends PureComponent {
|
||||
isSearchResult={this.props.isSearchResult}
|
||||
mentionName={mentionName}
|
||||
onPostPress={this.props.onPostPress}
|
||||
mentionKeys={this.props.mentionKeys}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -439,7 +432,7 @@ export default class Markdown extends PureComponent {
|
||||
ast = combineTextNodes(ast);
|
||||
ast = addListItemIndices(ast);
|
||||
ast = pullOutImages(ast);
|
||||
ast = highlightMentions(ast, this.getMentionKeys());
|
||||
ast = highlightMentions(ast, this.props.mentionKeys);
|
||||
|
||||
if (this.props.isEdited) {
|
||||
const editIndicatorNode = new Node('edited_indicator');
|
||||
|
||||
@@ -33,24 +33,4 @@ describe('Markdown', () => {
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('getMentionKeys', () => {
|
||||
let wrapper;
|
||||
beforeAll(() => {
|
||||
wrapper = shallow(
|
||||
<Markdown
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return base mentionKey props when disableAtChannelMentionHighlight not present', () => {
|
||||
expect(wrapper.instance().getMentionKeys()).toEqual(baseProps.mentionKeys);
|
||||
});
|
||||
|
||||
it('should filter channel mentions from mentionKey props when disableAtChannelMentionHighlight is true', () => {
|
||||
wrapper.setProps({disableAtChannelMentionHighlight: true});
|
||||
expect(wrapper.instance().getMentionKeys()).toEqual([{key: 'user.name'}]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ export default class MarkdownEmoji extends PureComponent {
|
||||
editedIndicator: this.renderEditedIndicator,
|
||||
emoji: this.renderEmoji,
|
||||
paragraph: this.renderParagraph,
|
||||
document: this.renderParagraph,
|
||||
text: this.renderText,
|
||||
},
|
||||
});
|
||||
@@ -66,7 +67,7 @@ export default class MarkdownEmoji extends PureComponent {
|
||||
renderParagraph = ({children}) => {
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
return (
|
||||
<View style={style.block}>{children}</View>
|
||||
<View style={style.block}><Text>{children}</Text></View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {getCustomEmojisByName} from '@mm-redux/selectors/entities/emojis';
|
||||
import {makeGetReactionsForPost} from '@mm-redux/selectors/entities/posts';
|
||||
import {memoizeResult} from '@mm-redux/utils/helpers';
|
||||
import {makeGetMentionKeysForPost} from '@mm-redux/selectors/entities/search';
|
||||
|
||||
import {
|
||||
isEdited,
|
||||
@@ -103,6 +104,7 @@ export function makeMapStateToProps() {
|
||||
isEmojiOnly,
|
||||
shouldRenderJumboEmoji,
|
||||
theme: getTheme(state),
|
||||
mentionKeys: makeGetMentionKeysForPost(state, postProps?.disable_group_highlight, postProps?.mentionHighlightDisabled),
|
||||
canDelete,
|
||||
...getDimensions(state),
|
||||
};
|
||||
|
||||
@@ -77,6 +77,13 @@ describe('makeMapStateToProps', () => {
|
||||
general: {
|
||||
serverVersion: '',
|
||||
},
|
||||
users: {
|
||||
profiles: {},
|
||||
},
|
||||
groups: {
|
||||
groups: {},
|
||||
myGroups: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const defaultOwnProps = {
|
||||
|
||||
@@ -71,6 +71,7 @@ export default class PostBody extends PureComponent {
|
||||
shouldRenderJumboEmoji: PropTypes.bool.isRequired,
|
||||
theme: PropTypes.object,
|
||||
location: PropTypes.string,
|
||||
mentionKeys: PropTypes.array.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -345,6 +346,7 @@ export default class PostBody extends PureComponent {
|
||||
shouldRenderJumboEmoji,
|
||||
showLongPost,
|
||||
theme,
|
||||
mentionKeys,
|
||||
} = this.props;
|
||||
const {isLongPost, maxHeight} = this.state;
|
||||
const style = getStyleSheet(theme);
|
||||
@@ -413,7 +415,7 @@ export default class PostBody extends PureComponent {
|
||||
onPostPress={onPress}
|
||||
textStyles={textStyles}
|
||||
value={message}
|
||||
disableAtChannelMentionHighlight={postProps.mentionHighlightDisabled}
|
||||
mentionKeys={mentionKeys}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -7,12 +7,13 @@ import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
|
||||
import {General, Permissions} from '@mm-redux/constants';
|
||||
import {createPost} from '@mm-redux/actions/posts';
|
||||
import {setStatus} from '@mm-redux/actions/users';
|
||||
import {getCurrentChannel, isCurrentChannelReadOnly, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels';
|
||||
import {getCurrentChannel, isCurrentChannelReadOnly, getCurrentChannelStats, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from '@mm-redux/selectors/entities/channels';
|
||||
import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles';
|
||||
import {getConfig} from '@mm-redux/selectors/entities/general';
|
||||
import {getConfig, getLicense} from '@mm-redux/selectors/entities/general';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities/users';
|
||||
import {getChannelTimezones} from '@mm-redux/actions/channels';
|
||||
import {getChannelTimezones, getChannelMemberCountsByGroup} from '@mm-redux/actions/channels';
|
||||
import {getAssociatedGroupsForReferenceMap} from '@mm-redux/selectors/entities/groups';
|
||||
|
||||
import {executeCommand} from '@actions/views/command';
|
||||
import {addReactionToLatestPost} from '@actions/views/emoji';
|
||||
@@ -36,8 +37,16 @@ export function mapStateToProps(state, ownProps) {
|
||||
const currentChannelStats = getCurrentChannelStats(state);
|
||||
const membersCount = currentChannelStats?.member_count || 0; // eslint-disable-line camelcase
|
||||
const isTimezoneEnabled = config?.ExperimentalTimezone === 'true';
|
||||
|
||||
const channelId = ownProps.channelId || (currentChannel ? currentChannel.id : '');
|
||||
const channelTeamId = currentChannel ? currentChannel.team_id : '';
|
||||
const license = getLicense(state);
|
||||
let canPost = true;
|
||||
let useChannelMentions = true;
|
||||
let deactivatedChannel = false;
|
||||
let useGroupMentions = false;
|
||||
const channelMemberCountsByGroup = selectChannelMemberCountsByGroup(state, channelId);
|
||||
let groupsWithAllowReference = new Map();
|
||||
|
||||
if (currentChannel && currentChannel.type === General.DM_CHANNEL) {
|
||||
const teammate = getChannelMembersForDm(state, currentChannel);
|
||||
if (teammate.length && teammate[0].delete_at) {
|
||||
@@ -45,8 +54,6 @@ export function mapStateToProps(state, ownProps) {
|
||||
}
|
||||
}
|
||||
|
||||
let canPost = true;
|
||||
let useChannelMentions = true;
|
||||
if (currentChannel && isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) {
|
||||
canPost = haveIChannelPermission(
|
||||
state,
|
||||
@@ -68,7 +75,20 @@ export function mapStateToProps(state, ownProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const channelId = ownProps.channelId || (currentChannel ? currentChannel.id : '');
|
||||
if (currentChannel && isMinimumServerVersion(state.entities.general.serverVersion, 5, 24) && license && license.IsLicensed === 'true') {
|
||||
useGroupMentions = haveIChannelPermission(
|
||||
state,
|
||||
{
|
||||
channel: currentChannel.id,
|
||||
team: currentChannel.team_id,
|
||||
permission: Permissions.USE_GROUP_MENTIONS,
|
||||
},
|
||||
);
|
||||
|
||||
if (useGroupMentions) {
|
||||
groupsWithAllowReference = getAssociatedGroupsForReferenceMap(state, channelTeamId, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
let channelIsReadOnly = false;
|
||||
if (currentUserId && channelId) {
|
||||
@@ -77,8 +97,10 @@ export function mapStateToProps(state, ownProps) {
|
||||
|
||||
return {
|
||||
canPost,
|
||||
channelDisplayName: state.views.channel.displayName || (currentChannel ? currentChannel.display_name : ''),
|
||||
currentChannel,
|
||||
channelId,
|
||||
channelTeamId,
|
||||
channelDisplayName: state.views.channel.displayName || (currentChannel ? currentChannel.display_name : ''),
|
||||
channelIsArchived: ownProps.channelIsArchived || (currentChannel ? currentChannel.delete_at !== 0 : false),
|
||||
channelIsReadOnly,
|
||||
currentUserId,
|
||||
@@ -94,6 +116,9 @@ export function mapStateToProps(state, ownProps) {
|
||||
useChannelMentions,
|
||||
userIsOutOfOffice,
|
||||
value: currentDraft.draft,
|
||||
groupsWithAllowReference,
|
||||
useGroupMentions,
|
||||
channelMemberCountsByGroup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,6 +131,7 @@ const mapDispatchToProps = {
|
||||
handleClearFailedFiles,
|
||||
initUploadFiles,
|
||||
setStatus,
|
||||
getChannelMemberCountsByGroup,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostDraft);
|
||||
|
||||
@@ -37,14 +37,20 @@ describe('mapStateToProps', () => {
|
||||
serverVersion: '',
|
||||
},
|
||||
users: {
|
||||
profiles: {},
|
||||
currentUserId: '',
|
||||
},
|
||||
channels: {
|
||||
currentChannelId: '',
|
||||
channelMemberCountsByGroup: {},
|
||||
channels: {},
|
||||
},
|
||||
preferences: {
|
||||
myPreferences: {},
|
||||
},
|
||||
teams: {
|
||||
teams: {},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
channel: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import Autocomplete from '@components/autocomplete';
|
||||
import {paddingHorizontal as padding} from '@components/safe_area_view/iphone_x_spacing';
|
||||
import {CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, CHANNEL_POST_TEXTBOX_VALUE_CHANGE, IS_REACTION_REGEX, MAX_FILE_COUNT} from '@constants/post_draft';
|
||||
import {NOTIFY_ALL_MEMBERS} from '@constants/view';
|
||||
import {AT_MENTION_REGEX_GLOBAL, CODE_REGEX} from 'app/constants/autocomplete';
|
||||
import {General} from '@mm-redux/constants';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {getFormattedFileSize} from '@mm-redux/utils/file_utils';
|
||||
@@ -31,6 +32,7 @@ export default class PostDraft extends PureComponent {
|
||||
static propTypes = {
|
||||
registerTypingAnimation: PropTypes.func.isRequired,
|
||||
addReactionToLatestPost: PropTypes.func.isRequired,
|
||||
getChannelMemberCountsByGroup: PropTypes.func.isRequired,
|
||||
canPost: PropTypes.bool.isRequired,
|
||||
channelDisplayName: PropTypes.string,
|
||||
channelId: PropTypes.string.isRequired,
|
||||
@@ -60,6 +62,9 @@ export default class PostDraft extends PureComponent {
|
||||
userIsOutOfOffice: PropTypes.bool.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
valueEvent: PropTypes.string,
|
||||
useGroupMentions: PropTypes.bool.isRequired,
|
||||
channelMemberCountsByGroup: PropTypes.object,
|
||||
groupsWithAllowReference: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -89,24 +94,33 @@ export default class PostDraft extends PureComponent {
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(prevProps) {
|
||||
if (this.props.isTimezoneEnabled !== prevProps?.isTimezoneEnabled || prevProps?.channelId !== this.props.channelId) {
|
||||
this.numberOfTimezones().then((channelTimezoneCount) => this.setState({channelTimezoneCount}));
|
||||
componentDidMount() {
|
||||
const {getChannelMemberCountsByGroup, channelId, isTimezoneEnabled, useGroupMentions} = this.props;
|
||||
if (useGroupMentions) {
|
||||
getChannelMemberCountsByGroup(channelId, isTimezoneEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.input.current) {
|
||||
const {channelId, rootId, value} = this.props;
|
||||
const diffChannel = channelId !== prevProps.channelId;
|
||||
const diffThread = rootId !== prevProps.rootId;
|
||||
const {channelId, rootId, value, useGroupMentions, getChannelMemberCountsByGroup, isTimezoneEnabled} = this.props;
|
||||
const diffChannel = channelId !== prevProps?.channelId;
|
||||
const diffTimezoneEnabled = isTimezoneEnabled !== prevProps?.isTimezoneEnabled;
|
||||
|
||||
if (this.input.current) {
|
||||
const diffThread = rootId !== prevProps.rootId;
|
||||
if (diffChannel || diffThread) {
|
||||
const trimmed = value.trim();
|
||||
this.input.current.setValue(trimmed);
|
||||
this.updateInitialValue(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (diffTimezoneEnabled || diffChannel) {
|
||||
this.numberOfTimezones().then((channelTimezoneCount) => this.setState({channelTimezoneCount}));
|
||||
if (useGroupMentions) {
|
||||
getChannelMemberCountsByGroup(channelId, isTimezoneEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blurTextBox = () => {
|
||||
@@ -132,9 +146,101 @@ export default class PostDraft extends PureComponent {
|
||||
return messageLength > 0;
|
||||
};
|
||||
|
||||
showSendToGroupsAlert = (groupMentions, memberNotifyCount, channelTimezoneCount, msg) => {
|
||||
const {intl} = this.context;
|
||||
|
||||
let notifyAllMessage = '';
|
||||
if (groupMentions.length === 1) {
|
||||
if (channelTimezoneCount > 0) {
|
||||
notifyAllMessage = (
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: 'mobile.post_textbox.one_group.message.with_timezones',
|
||||
defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?',
|
||||
},
|
||||
{
|
||||
mention: groupMentions[0],
|
||||
totalMembers: memberNotifyCount,
|
||||
timezones: channelTimezoneCount,
|
||||
},
|
||||
)
|
||||
);
|
||||
} else {
|
||||
notifyAllMessage = (
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: 'mobile.post_textbox.one_group.message.without_timezones',
|
||||
defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people. Are you sure you want to do this?',
|
||||
},
|
||||
{
|
||||
mention: groupMentions[0],
|
||||
totalMembers: memberNotifyCount,
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (channelTimezoneCount > 0) {
|
||||
notifyAllMessage = (
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: 'mobile.post_textbox.multi_group.message.with_timezones',
|
||||
defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?',
|
||||
},
|
||||
{
|
||||
mentions: groupMentions.slice(0, -1).join(', '),
|
||||
finalMention: groupMentions[groupMentions.length - 1],
|
||||
totalMembers: memberNotifyCount,
|
||||
timezones: channelTimezoneCount,
|
||||
},
|
||||
)
|
||||
);
|
||||
} else {
|
||||
notifyAllMessage = (
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: 'mobile.post_textbox.multi_group.message.without_timezones',
|
||||
defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people. Are you sure you want to do this?',
|
||||
},
|
||||
{
|
||||
mentions: groupMentions.slice(0, -1).join(', '),
|
||||
finalMention: groupMentions[groupMentions.length - 1],
|
||||
totalMembers: memberNotifyCount,
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.post_textbox.groups.title',
|
||||
defaultMessage: 'Confirm sending notifications to groups',
|
||||
}),
|
||||
notifyAllMessage,
|
||||
[
|
||||
{
|
||||
text: intl.formatMessage({
|
||||
id: 'mobile.post_textbox.entire_channel.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
}),
|
||||
onPress: () => {
|
||||
this.input.current.setValue(msg);
|
||||
this.setState({sendingMessage: false});
|
||||
},
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({
|
||||
id: 'mobile.post_textbox.entire_channel.confirm',
|
||||
defaultMessage: 'Confirm',
|
||||
}),
|
||||
onPress: () => this.doSubmitMessage(),
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
doSubmitMessage = () => {
|
||||
const {createPost, currentUserId, channelId, files, handleClearFiles, rootId} = this.props;
|
||||
const value = this.input.current.getValue();
|
||||
const value = this.input.current?.getValue() || '';
|
||||
const postFiles = files.filter((f) => !f.failed);
|
||||
const post = {
|
||||
user_id: currentUserId,
|
||||
@@ -150,10 +256,12 @@ export default class PostDraft extends PureComponent {
|
||||
handleClearFiles(channelId, rootId);
|
||||
}
|
||||
|
||||
this.input.current.setValue('');
|
||||
this.setState({sendingMessage: false});
|
||||
if (this.input.current) {
|
||||
this.input.current.setValue('');
|
||||
this.input.current.changeDraft('');
|
||||
}
|
||||
|
||||
this.input.current.changeDraft('');
|
||||
this.setState({sendingMessage: false});
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
// Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages
|
||||
@@ -362,21 +470,44 @@ export default class PostDraft extends PureComponent {
|
||||
this.input.current.changeDraft('');
|
||||
};
|
||||
|
||||
mapGroupMentions = (groupMentions) => {
|
||||
const {channelMemberCountsByGroup} = this.props;
|
||||
let memberNotifyCount = 0;
|
||||
let channelTimezoneCount = 0;
|
||||
const groupMentionsSet = new Set();
|
||||
groupMentions.
|
||||
forEach((group) => {
|
||||
const mappedValue = channelMemberCountsByGroup[group.id];
|
||||
if (mappedValue?.channel_member_count > NOTIFY_ALL_MEMBERS && mappedValue?.channel_member_count > memberNotifyCount) {
|
||||
memberNotifyCount = mappedValue.channel_member_count;
|
||||
channelTimezoneCount = mappedValue.channel_member_timezones_count;
|
||||
}
|
||||
groupMentionsSet.add(`@${group.name}`);
|
||||
});
|
||||
return {groupMentionsSet, memberNotifyCount, channelTimezoneCount};
|
||||
}
|
||||
|
||||
sendMessage = () => {
|
||||
const value = this.input.current.getValue();
|
||||
const value = this.input.current?.getValue() || '';
|
||||
const {enableConfirmNotificationsToChannel, membersCount, useGroupMentions, useChannelMentions} = this.props;
|
||||
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
|
||||
const notificationsToGroups = enableConfirmNotificationsToChannel && useGroupMentions;
|
||||
const toAllOrChannel = this.textContainsAtAllAtChannel(value);
|
||||
const groupMentions = (!toAllOrChannel && notificationsToGroups) ? this.groupsMentionedInText(value) : [];
|
||||
|
||||
if (value) {
|
||||
const {enableConfirmNotificationsToChannel, membersCount, useChannelMentions} = this.props;
|
||||
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
|
||||
const toAllOrChannel = this.textContainsAtAllAtChannel(value);
|
||||
|
||||
if (value.indexOf('/') === 0) {
|
||||
this.sendCommand(value);
|
||||
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) {
|
||||
this.showSendToAllOrChannelAlert(membersCount, value);
|
||||
if (value.indexOf('/') === 0) {
|
||||
this.sendCommand(value);
|
||||
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) {
|
||||
this.showSendToAllOrChannelAlert(membersCount, value);
|
||||
} else if (groupMentions.length > 0) {
|
||||
const {groupMentionsSet, memberNotifyCount, channelTimezoneCount} = this.mapGroupMentions(groupMentions);
|
||||
if (memberNotifyCount > 0) {
|
||||
this.showSendToGroupsAlert(Array.from(groupMentionsSet), memberNotifyCount, channelTimezoneCount, value);
|
||||
} else {
|
||||
this.doSubmitMessage();
|
||||
}
|
||||
} else {
|
||||
this.doSubmitMessage();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -474,10 +605,26 @@ export default class PostDraft extends PureComponent {
|
||||
};
|
||||
|
||||
textContainsAtAllAtChannel = (text) => {
|
||||
const textWithoutCode = text.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)| *(`{3,}|~{3,})[ .]*(\S+)? *\n([\s\S]*?\s*)\3 *(?:\n+|$)/g, '');
|
||||
return (/\B@(all|channel)\b/i).test(textWithoutCode);
|
||||
const textWithoutCode = text.replace(CODE_REGEX, '');
|
||||
return (/(?:\B|\b_+)@(channel|all)(?!(\.|-|_)*[^\W_])/i).test(textWithoutCode);
|
||||
};
|
||||
|
||||
groupsMentionedInText = (text) => {
|
||||
const {groupsWithAllowReference} = this.props;
|
||||
const groups = [];
|
||||
if (groupsWithAllowReference.size > 0) {
|
||||
const textWithoutCode = text.replace(CODE_REGEX, '');
|
||||
const mentions = textWithoutCode.match(AT_MENTION_REGEX_GLOBAL) || [];
|
||||
mentions.forEach((mention) => {
|
||||
const group = groupsWithAllowReference.get(mention);
|
||||
if (group) {
|
||||
groups.push(group);
|
||||
}
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
updateInitialValue = (value) => {
|
||||
this.setState({value});
|
||||
}
|
||||
@@ -531,6 +678,8 @@ export default class PostDraft extends PureComponent {
|
||||
maxHeight={Math.min(this.state.top - AUTOCOMPLETE_MARGIN, AUTOCOMPLETE_MAX_HEIGHT)}
|
||||
onChangeText={this.handleInputQuickAction}
|
||||
valueEvent={valueEvent}
|
||||
rootId={rootId}
|
||||
channelId={channelId}
|
||||
/>
|
||||
}
|
||||
<View
|
||||
|
||||
349
app/components/post_draft/post_draft.test.js
Normal file
349
app/components/post_draft/post_draft.test.js
Normal file
@@ -0,0 +1,349 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Alert} from 'react-native';
|
||||
import assert from 'assert';
|
||||
import {shallowWithIntl} from 'test/intl-test-helper';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import PostDraft from './post_draft';
|
||||
|
||||
jest.mock('react-native-image-picker', () => ({
|
||||
launchCamera: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('PostDraft', () => {
|
||||
const baseProps = {
|
||||
addReactionToLatestPost: jest.fn(),
|
||||
createPost: jest.fn(),
|
||||
executeCommand: jest.fn(),
|
||||
handleCommentDraftChanged: jest.fn(),
|
||||
handlePostDraftChanged: jest.fn(),
|
||||
handleClearFiles: jest.fn(),
|
||||
handleClearFailedFiles: jest.fn(),
|
||||
handleRemoveLastFile: jest.fn(),
|
||||
initUploadFiles: jest.fn(),
|
||||
userTyping: jest.fn(),
|
||||
handleCommentDraftSelectionChanged: jest.fn(),
|
||||
setStatus: jest.fn(),
|
||||
selectPenultimateChannel: jest.fn(),
|
||||
getChannelTimezones: jest.fn(),
|
||||
getChannelMemberCountsByGroup: jest.fn(),
|
||||
canUploadFiles: true,
|
||||
channelId: 'channel-id',
|
||||
channelDisplayName: 'Test Channel',
|
||||
channelTeamId: 'channel-team-id',
|
||||
channelIsReadOnly: false,
|
||||
currentUserId: 'current-user-id',
|
||||
deactivatedChannel: false,
|
||||
files: [],
|
||||
maxFileSize: 1024,
|
||||
maxMessageLength: 4000,
|
||||
rootId: '',
|
||||
theme: Preferences.THEMES.default,
|
||||
uploadFileRequestStatus: 'NOT_STARTED',
|
||||
value: '',
|
||||
userIsOutOfOffice: false,
|
||||
channelIsArchived: false,
|
||||
onCloseChannel: jest.fn(),
|
||||
cursorPositionEvent: '',
|
||||
valueEvent: '',
|
||||
isLandscape: false,
|
||||
screenId: 'NavigationScreen1',
|
||||
canPost: true,
|
||||
currentChannelMembersCount: 50,
|
||||
enableConfirmNotificationsToChannel: true,
|
||||
useChannelMentions: true,
|
||||
useGroupMentions: true,
|
||||
groupsWithAllowReference: new Map([
|
||||
['@developers', {
|
||||
id: 'developers',
|
||||
name: 'developers',
|
||||
}],
|
||||
['@qa', {
|
||||
id: 'qa',
|
||||
name: 'qa',
|
||||
}],
|
||||
]),
|
||||
channelMemberCountsByGroup: {
|
||||
developers: {
|
||||
channel_member_count: 10,
|
||||
channel_member_timezones_count: 0,
|
||||
},
|
||||
qa: {
|
||||
channel_member_count: 3,
|
||||
channel_member_timezones_count: 0,
|
||||
},
|
||||
},
|
||||
membersCount: 10,
|
||||
};
|
||||
const ref = React.createRef();
|
||||
|
||||
test('should send an alert when sending a message with a channel mention', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<PostDraft
|
||||
{...baseProps}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
const message = '@all';
|
||||
const instance = wrapper.instance();
|
||||
expect(instance.input).toEqual({current: null});
|
||||
instance.input = {
|
||||
current: {
|
||||
getValue: () => message,
|
||||
setValue: jest.fn(),
|
||||
changeDraft: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
instance.sendMessage();
|
||||
expect(Alert.alert).toBeCalled();
|
||||
expect(Alert.alert).toHaveBeenCalledWith('Confirm sending notifications to entire channel', expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
test('should send an alert when sending a message with a group mention with group with count more than NOTIFY_ALL', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<PostDraft
|
||||
{...baseProps}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
const message = '@developers';
|
||||
const instance = wrapper.instance();
|
||||
expect(instance.input).toEqual({current: null});
|
||||
instance.input = {
|
||||
current: {
|
||||
getValue: () => message,
|
||||
setValue: jest.fn(),
|
||||
changeDraft: jest.fn(),
|
||||
},
|
||||
};
|
||||
instance.sendMessage();
|
||||
expect(Alert.alert).toBeCalled();
|
||||
});
|
||||
|
||||
test('should not send an alert when sending a message with a group mention with group with count less than NOTIFY_ALL', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<PostDraft
|
||||
{...baseProps}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
const message = '@qa';
|
||||
const instance = wrapper.instance();
|
||||
expect(instance.input).toEqual({current: null});
|
||||
instance.input = {
|
||||
current: {
|
||||
getValue: () => message,
|
||||
setValue: jest.fn(),
|
||||
changeDraft: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
instance.sendMessage();
|
||||
expect(Alert.alert).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('should not send an alert when sending a message with a channel mention when the user does not have channel mentions permission', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<PostDraft
|
||||
{...baseProps}
|
||||
useChannelMentions={false}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
const message = '@all';
|
||||
const instance = wrapper.instance();
|
||||
expect(instance.input).toEqual({current: null});
|
||||
instance.input = {
|
||||
current: {
|
||||
getValue: () => message,
|
||||
setValue: jest.fn(),
|
||||
changeDraft: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
instance.sendMessage();
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not send an alert when sending a message with a channel mention when the user does not have group mentions permission', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<PostDraft
|
||||
{...baseProps}
|
||||
useGroupMentions={false}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
const message = '@developer';
|
||||
const instance = wrapper.instance();
|
||||
expect(instance.input).toEqual({current: null});
|
||||
instance.input = {
|
||||
current: {
|
||||
getValue: () => message,
|
||||
setValue: jest.fn(),
|
||||
changeDraft: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
instance.sendMessage();
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return correct @all (same for @channel)', () => {
|
||||
for (const data of [
|
||||
{
|
||||
text: '',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: 'all',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '@allison',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '@ALLISON',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '@all123',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '123@all',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: 'hey@all',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: 'hey@all.com',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '@all',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: '@ALL',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: '@all hey',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: 'hey @all',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: 'HEY @ALL',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: 'hey @all!',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: 'hey @all:+1:',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: 'hey @ALL:+1:',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: '`@all`',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '@someone `@all`',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '``@all``',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '```@all```',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '```\n@all\n```',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '```````\n@all\n```````',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '```code\n@all\n```',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '~~~@all~~~',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: '~~~\n@all\n~~~',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: ' /not_cmd @all',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: '@channel',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: '@channel.',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: '@channel/test',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: 'test/@channel',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: '@all/@channel',
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
text: '@cha*nnel*',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '@cha**nnel**',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '*@cha*nnel',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '[@chan](https://google.com)nel',
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
text: '@channel',
|
||||
result: false,
|
||||
},
|
||||
]) {
|
||||
const wrapper = shallowWithIntl(
|
||||
<PostDraft {...baseProps}/>,
|
||||
);
|
||||
const containsAtChannel = wrapper.instance().textContainsAtAllAtChannel(data.text);
|
||||
assert.equal(containsAtChannel, data.result, data.text);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ export default class QuickActions extends PureComponent {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
inputValue: props.initialValue,
|
||||
inputValue: '',
|
||||
atDisabled: props.readonly,
|
||||
slashDisabled: props.readonly,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import ImageCacheManager from '@utils/image_cache_manager';
|
||||
|
||||
import UploadRemove from './upload_remove';
|
||||
import UploadRetry from './upload_retry';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
|
||||
export default class UploadItem extends PureComponent {
|
||||
static propTypes = {
|
||||
@@ -147,7 +148,7 @@ export default class UploadItem extends PureComponent {
|
||||
fileInfo,
|
||||
];
|
||||
|
||||
Client4.trackEvent('api', 'api_files_upload');
|
||||
analytics.trackAPI('api_files_upload');
|
||||
|
||||
const certificate = await mattermostBucket.getPreference('cert');
|
||||
const options = {
|
||||
|
||||
@@ -115,6 +115,7 @@ export default class MoreMessageButton extends React.PureComponent {
|
||||
// In this case we want to manually call onViewableItemsChanged with the stored
|
||||
// viewableItems.
|
||||
if (unreadCount > prevProps.unreadCount && prevProps.unreadCount === 0) {
|
||||
this.uncancel();
|
||||
this.onViewableItemsChanged(this.viewableItems);
|
||||
}
|
||||
|
||||
|
||||
@@ -207,22 +207,26 @@ describe('MoreMessagesButton', () => {
|
||||
expect(instance.showMoreText).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
test('componentDidUpdate should call onViewableItemsChanged when the unreadCount increases from 0', () => {
|
||||
test('componentDidUpdate should call uncancel and onViewableItemsChanged when the unreadCount increases from 0', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<MoreMessagesButton {...baseProps}/>,
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
instance.uncancel = jest.fn();
|
||||
instance.onViewableItemsChanged = jest.fn();
|
||||
instance.viewableItems = [{index: 1}];
|
||||
|
||||
wrapper.setProps({unreadCount: 0});
|
||||
expect(instance.uncancel).not.toHaveBeenCalled();
|
||||
expect(instance.onViewableItemsChanged).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.setProps({unreadCount: 1});
|
||||
expect(instance.uncancel).toHaveBeenCalledTimes(1);
|
||||
expect(instance.onViewableItemsChanged).toHaveBeenCalledTimes(1);
|
||||
expect(instance.onViewableItemsChanged).toHaveBeenCalledWith(instance.viewableItems);
|
||||
|
||||
wrapper.setProps({unreadCount: 2});
|
||||
expect(instance.uncancel).toHaveBeenCalledTimes(1);
|
||||
expect(instance.onViewableItemsChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as PostListUtils from '@mm-redux/utils/post_list';
|
||||
|
||||
import CombinedUserActivityPost from 'app/components/combined_user_activity_post';
|
||||
import Post from 'app/components/post';
|
||||
import {DeepLinkTypes, ListTypes} from 'app/constants';
|
||||
import {DeepLinkTypes, ListTypes, NavigationTypes} from '@constants';
|
||||
import mattermostManaged from 'app/mattermost_managed';
|
||||
import {makeExtraData} from 'app/utils/list_view';
|
||||
import {changeOpacity} from 'app/utils/theme';
|
||||
@@ -105,6 +105,7 @@ export default class PostList extends PureComponent {
|
||||
const {actions, deepLinkURL, highlightPostId, initialIndex} = this.props;
|
||||
|
||||
EventEmitter.on('scroll-to-bottom', this.handleSetScrollToBottom);
|
||||
EventEmitter.on(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT, this.handleClosePermalink);
|
||||
|
||||
// Invoked when hitting a deep link and app is not already running.
|
||||
if (deepLinkURL) {
|
||||
@@ -149,6 +150,7 @@ export default class PostList extends PureComponent {
|
||||
|
||||
componentWillUnmount() {
|
||||
EventEmitter.off('scroll-to-bottom', this.handleSetScrollToBottom);
|
||||
EventEmitter.off(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT, this.handleClosePermalink);
|
||||
|
||||
this.resetPostList();
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
import * as NavigationActions from 'app/actions/navigation';
|
||||
import {NavigationTypes} from '@constants';
|
||||
import PostList from './post_list';
|
||||
|
||||
jest.useFakeTimers();
|
||||
@@ -121,4 +123,36 @@ describe('PostList', () => {
|
||||
|
||||
expect(instance.loadToFillContent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should register listeners on componentDidMount', () => {
|
||||
const wrapper = shallow(
|
||||
<PostList {...baseProps}/>,
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
instance.handleSetScrollToBottom = jest.fn();
|
||||
instance.handleClosePermalink = jest.fn();
|
||||
EventEmitter.on = jest.fn();
|
||||
|
||||
expect(EventEmitter.on).not.toHaveBeenCalled();
|
||||
instance.componentDidMount();
|
||||
expect(EventEmitter.on).toHaveBeenCalledTimes(2);
|
||||
expect(EventEmitter.on).toHaveBeenCalledWith('scroll-to-bottom', instance.handleSetScrollToBottom);
|
||||
expect(EventEmitter.on).toHaveBeenCalledWith(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT, instance.handleClosePermalink);
|
||||
});
|
||||
|
||||
test('should remove listeners on componentWillUnmount', () => {
|
||||
const wrapper = shallow(
|
||||
<PostList {...baseProps}/>,
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
instance.handleSetScrollToBottom = jest.fn();
|
||||
instance.handleClosePermalink = jest.fn();
|
||||
EventEmitter.off = jest.fn();
|
||||
|
||||
expect(EventEmitter.off).not.toHaveBeenCalled();
|
||||
instance.componentWillUnmount();
|
||||
expect(EventEmitter.off).toHaveBeenCalledTimes(2);
|
||||
expect(EventEmitter.off).toHaveBeenCalledWith('scroll-to-bottom', instance.handleSetScrollToBottom);
|
||||
expect(EventEmitter.off).toHaveBeenCalledWith(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT, instance.handleClosePermalink);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
export const AT_MENTION_REGEX = /\B(@([^@\r\n\s]*))$/i;
|
||||
|
||||
export const AT_MENTION_REGEX_GLOBAL = /\B(@([^@\r\n\s]*))/gi;
|
||||
|
||||
export const AT_MENTION_SEARCH_REGEX = /\bfrom:\s*(\S*)$/i;
|
||||
|
||||
export const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i;
|
||||
@@ -11,4 +13,6 @@ export const CHANNEL_MENTION_SEARCH_REGEX = /\b(?:in|channel):\s*(\S*)$/i;
|
||||
|
||||
export const DATE_MENTION_SEARCH_REGEX = /\b(?:on|before|after):\s*(\S*)$/i;
|
||||
|
||||
export const ALL_SEARCH_FLAGS_REGEX = /\b\w+:/g;
|
||||
export const ALL_SEARCH_FLAGS_REGEX = /\b\w+:/g;
|
||||
|
||||
export const CODE_REGEX = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)| *(`{3,}|~{3,})[ .]*(\S+)? *\n([\s\S]*?\s*)\3 *(?:\n+|$)/g;
|
||||
|
||||
@@ -10,6 +10,7 @@ const NavigationTypes = keyMirror({
|
||||
RESTART_APP: null,
|
||||
NAVIGATION_ERROR_TEAMS: null,
|
||||
NAVIGATION_SHOW_OVERLAY: null,
|
||||
NAVIGATION_DISMISS_AND_POP_TO_ROOT: null,
|
||||
CLOSE_MAIN_SIDEBAR: null,
|
||||
MAIN_SIDEBAR_DID_CLOSE: null,
|
||||
MAIN_SIDEBAR_DID_OPEN: null,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {Dimensions} from 'react-native';
|
||||
import LocalConfig from '@assets/config.json';
|
||||
import {Config} from '@mm-redux/types/config';
|
||||
import tracker from '@utils/time_tracker';
|
||||
import {isSystemAdmin} from '@mm-redux/utils/user_utils';
|
||||
|
||||
type RudderClient = {
|
||||
setup(key: string, options: any): Promise<void>;
|
||||
@@ -16,69 +17,147 @@ type RudderClient = {
|
||||
reset(): Promise<void>;
|
||||
}
|
||||
|
||||
let diagnosticId: string | undefined;
|
||||
export let analytics: RudderClient | null = null;
|
||||
export let context: any;
|
||||
class Analytics {
|
||||
analytics: RudderClient | null = null;
|
||||
context: any;
|
||||
diagnosticId: string | undefined;
|
||||
|
||||
export async function init(config: Config) {
|
||||
if (!analytics) {
|
||||
analytics = require('@rudderstack/rudder-sdk-react-native').default;
|
||||
userRoles: string | null = null;
|
||||
userId = '';
|
||||
|
||||
async init(config: Config) {
|
||||
this.analytics = require('@rudderstack/rudder-sdk-react-native').default;
|
||||
|
||||
if (this.analytics) {
|
||||
const {height, width} = Dimensions.get('window');
|
||||
this.diagnosticId = config.DiagnosticId;
|
||||
|
||||
if (this.diagnosticId) {
|
||||
await this.analytics.setup(LocalConfig.RudderApiKey, {
|
||||
dataPlaneUrl: 'https://pdat.matterlytics.com',
|
||||
recordScreenViews: true,
|
||||
flushQueueSize: 20,
|
||||
});
|
||||
|
||||
this.context = {
|
||||
app: {
|
||||
version: DeviceInfo.getVersion(),
|
||||
build: DeviceInfo.getBuildNumber(),
|
||||
},
|
||||
device: {
|
||||
dimensions: {
|
||||
height,
|
||||
width,
|
||||
},
|
||||
isTablet: DeviceInfo.isTablet(),
|
||||
os: DeviceInfo.getSystemVersion(),
|
||||
},
|
||||
ip: '0.0.0.0',
|
||||
server: config.Version,
|
||||
};
|
||||
|
||||
this.analytics.identify(
|
||||
this.diagnosticId,
|
||||
this.context,
|
||||
);
|
||||
} else {
|
||||
this.analytics.reset();
|
||||
}
|
||||
}
|
||||
|
||||
return this.analytics;
|
||||
}
|
||||
|
||||
if (analytics) {
|
||||
const {height, width} = Dimensions.get('window');
|
||||
diagnosticId = config.DiagnosticId;
|
||||
|
||||
if (diagnosticId) {
|
||||
await analytics.setup(LocalConfig.RudderApiKey, {
|
||||
dataPlaneUrl: 'https://pdat.matterlytics.com',
|
||||
recordScreenViews: true,
|
||||
flushQueueSize: 20,
|
||||
});
|
||||
|
||||
context = {
|
||||
app: {
|
||||
version: DeviceInfo.getVersion(),
|
||||
build: DeviceInfo.getBuildNumber(),
|
||||
},
|
||||
device: {
|
||||
dimensions: {
|
||||
height,
|
||||
width,
|
||||
},
|
||||
isTablet: DeviceInfo.isTablet(),
|
||||
os: DeviceInfo.getSystemVersion(),
|
||||
},
|
||||
ip: '0.0.0.0',
|
||||
server: config.Version,
|
||||
};
|
||||
|
||||
analytics.identify(
|
||||
diagnosticId,
|
||||
context,
|
||||
);
|
||||
} else {
|
||||
analytics.reset();
|
||||
async reset() {
|
||||
this.userId = '';
|
||||
this.userRoles = null;
|
||||
if (this.analytics) {
|
||||
await this.analytics.reset();
|
||||
}
|
||||
}
|
||||
|
||||
return analytics;
|
||||
}
|
||||
setUserId(userId: string) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
export function recordTime(screenName: string, category: string, userId: string) {
|
||||
if (analytics) {
|
||||
const track: Record<string, number> = tracker;
|
||||
const startTime: number = track[category];
|
||||
track[category] = 0;
|
||||
analytics.screen(
|
||||
screenName, {
|
||||
userId: diagnosticId,
|
||||
context,
|
||||
properties: {
|
||||
actual_user_id: userId,
|
||||
time: Date.now() - startTime,
|
||||
setUserRoles(roles: string) {
|
||||
this.userRoles = roles;
|
||||
}
|
||||
|
||||
trackEvent(category: string, event: string, props?: any) {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const properties = Object.assign({
|
||||
category,
|
||||
type: event,
|
||||
user_actual_role: this.userRoles && isSystemAdmin(this.userRoles) ? 'system_admin, system_user' : 'system_user',
|
||||
user_actual_id: this.userId,
|
||||
}, props);
|
||||
const options = {
|
||||
context: this.context,
|
||||
anonymousId: '00000000000000000000000000',
|
||||
};
|
||||
|
||||
this.analytics.track(event, properties, options);
|
||||
}
|
||||
|
||||
recordTime(screenName: string, category: string, userId: string) {
|
||||
if (this.analytics) {
|
||||
const track: Record<string, number> = tracker;
|
||||
const startTime: number = track[category];
|
||||
track[category] = 0;
|
||||
this.analytics.screen(
|
||||
screenName, {
|
||||
userId: this.diagnosticId,
|
||||
context: this.context,
|
||||
properties: {
|
||||
user_actual_id: userId,
|
||||
time: Date.now() - startTime,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
trackAPI(event: string, props?: any) {
|
||||
this.trackEvent('api', event, props);
|
||||
}
|
||||
|
||||
trackCommand(event: string, command: string, errorMessage?: string) {
|
||||
const sanitizedCommand = this.sanitizeCommand(command);
|
||||
let props: any;
|
||||
if (errorMessage) {
|
||||
props = {command: sanitizedCommand, error: errorMessage};
|
||||
} else {
|
||||
props = {command: sanitizedCommand};
|
||||
}
|
||||
|
||||
this.trackEvent('command', event, props);
|
||||
}
|
||||
|
||||
trackAction(event: string, props?: any) {
|
||||
this.trackEvent('action', event, props);
|
||||
}
|
||||
|
||||
sanitizeCommand(userInput: string): string {
|
||||
const commandList = ['agenda', 'autolink', 'away', 'bot-server', 'code', 'collapse',
|
||||
'dnd', 'echo', 'expand', 'export', 'giphy', 'github', 'groupmsg', 'header', 'help',
|
||||
'invite', 'invite_people', 'jira', 'jitsi', 'join', 'kick', 'leave', 'logout', 'me',
|
||||
'msg', 'mute', 'nc', 'offline', 'online', 'open', 'poll', 'poll2', 'post-mortem',
|
||||
'purpose', 'recommend', 'remove', 'rename', 'search', 'settings', 'shortcuts',
|
||||
'shrug', 'standup', 'todo', 'wrangler', 'zoom'];
|
||||
const index = userInput.indexOf(' ');
|
||||
if (index === -1) {
|
||||
return userInput[0];
|
||||
}
|
||||
const command = userInput.substring(1, index);
|
||||
if (commandList.includes(command)) {
|
||||
return command;
|
||||
}
|
||||
return 'custom_command';
|
||||
}
|
||||
}
|
||||
|
||||
export const analytics = new Analytics();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {Client4} from '@mm-redux/client';
|
||||
import mattermostManaged from 'app/mattermost_managed';
|
||||
import EphemeralStore from 'app/store/ephemeral_store';
|
||||
import {setCSRFFromCookie} from 'app/utils/security';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
|
||||
const CURRENT_SERVER = '@currentServerUrl';
|
||||
|
||||
@@ -56,7 +57,6 @@ export const removeAppCredentials = async () => {
|
||||
|
||||
Client4.setCSRF('');
|
||||
Client4.serverVersion = '';
|
||||
Client4.setUserId('');
|
||||
Client4.setToken('');
|
||||
Client4.setUrl('');
|
||||
|
||||
@@ -84,7 +84,7 @@ async function getCredentialsFromGenericKeyChain() {
|
||||
|
||||
// if for any case the url and the token aren't valid proceed with re-hydration
|
||||
if (url && url !== 'undefined' && token && token !== 'undefined') {
|
||||
Client4.setUserId(currentUserId);
|
||||
analytics.setUserId(currentUserId);
|
||||
Client4.setUrl(url);
|
||||
Client4.setToken(token);
|
||||
await setCSRFFromCookie(url);
|
||||
@@ -116,7 +116,7 @@ async function getInternetCredentials(url) {
|
||||
|
||||
if (token && token !== 'undefined') {
|
||||
EphemeralStore.deviceToken = deviceToken;
|
||||
Client4.setUserId(currentUserId);
|
||||
analytics.setUserId(currentUserId);
|
||||
Client4.setUrl(url);
|
||||
Client4.setToken(token);
|
||||
await setCSRFFromCookie(url);
|
||||
|
||||
@@ -41,6 +41,8 @@ const handleRedirectProtocol = (url, response) => {
|
||||
};
|
||||
|
||||
Client4.doFetchWithResponse = async (url, options) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Request endpoint', url);
|
||||
const customHeaders = LocalConfig.CustomRequestHeaders;
|
||||
let waitsForConnectivity = false;
|
||||
let timeoutIntervalForResource = 30;
|
||||
@@ -87,6 +89,7 @@ Client4.doFetchWithResponse = async (url, options) => {
|
||||
message: 'You need to use a valid client certificate in order to connect to this Mattermost server',
|
||||
status_code: 401,
|
||||
url,
|
||||
details: err,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,6 +100,7 @@ Client4.doFetchWithResponse = async (url, options) => {
|
||||
defaultMessage: 'Received invalid response from the server.',
|
||||
},
|
||||
url,
|
||||
details: err,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,12 +42,11 @@ import PushNotifications from 'app/push_notifications';
|
||||
|
||||
import {getAppCredentials, removeAppCredentials} from './credentials';
|
||||
import emmProvider from './emm_provider';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
|
||||
const {StatusBarManager} = NativeModules;
|
||||
const PROMPT_IN_APP_PIN_CODE_AFTER = 5 * 1000;
|
||||
|
||||
let analytics;
|
||||
|
||||
class GlobalEventHandler {
|
||||
constructor() {
|
||||
this.pushNotificationListener = false;
|
||||
@@ -120,13 +119,10 @@ class GlobalEventHandler {
|
||||
configureAnalytics = async () => {
|
||||
const state = Store.redux.getState();
|
||||
const config = getConfig(state);
|
||||
const initAnalytics = require('./analytics').init;
|
||||
|
||||
if (config && config.DiagnosticsEnabled === 'true' && config.DiagnosticId && LocalConfig.RudderApiKey) {
|
||||
analytics = await initAnalytics(config);
|
||||
await analytics.init(config);
|
||||
}
|
||||
|
||||
return analytics;
|
||||
};
|
||||
|
||||
onAppStateChange = (appState) => {
|
||||
@@ -204,9 +200,7 @@ class GlobalEventHandler {
|
||||
Store.redux.dispatch(closeWebSocket(false));
|
||||
Store.redux.dispatch(setServerVersion(''));
|
||||
|
||||
if (analytics) {
|
||||
await analytics.reset();
|
||||
}
|
||||
await analytics.reset();
|
||||
|
||||
mattermostBucket.removePreference('cert');
|
||||
mattermostBucket.removePreference('emm');
|
||||
|
||||
@@ -78,6 +78,8 @@ export default keyMirror({
|
||||
|
||||
RECEIVED_CHANNEL_MODERATIONS: null,
|
||||
|
||||
RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP: null,
|
||||
|
||||
RECEIVED_TOTAL_CHANNEL_COUNT: null,
|
||||
|
||||
POST_UNREAD_SUCCESS: null,
|
||||
|
||||
@@ -13,6 +13,8 @@ export default keyMirror({
|
||||
RECEIVED_CUSTOM_TEAM_COMMANDS: null,
|
||||
RECEIVED_COMMAND: null,
|
||||
RECEIVED_COMMANDS: null,
|
||||
RECEIVED_COMMAND_SUGGESTIONS: null,
|
||||
RECEIVED_COMMAND_SUGGESTIONS_FAILURE: null,
|
||||
RECEIVED_COMMAND_TOKEN: null,
|
||||
DELETED_COMMAND: null,
|
||||
RECEIVED_OAUTH_APP: null,
|
||||
|
||||
@@ -2320,4 +2320,34 @@ describe('Actions.Channels', () => {
|
||||
assert.equal(moderations[0].roles.members, true);
|
||||
assert.equal(moderations[0].roles.guests, false);
|
||||
});
|
||||
|
||||
it('getChannelMemberCountsByGroup', async () => {
|
||||
const channelID = 'cid10000000000000000000000';
|
||||
|
||||
nock(Client4.getBaseRoute()).get(
|
||||
`/channels/${channelID}/member_counts_by_group?include_timezones=true`).
|
||||
reply(200, [
|
||||
{
|
||||
group_id: 'group-1',
|
||||
channel_member_count: 1,
|
||||
channel_member_timezones_count: 1,
|
||||
},
|
||||
{
|
||||
group_id: 'group-2',
|
||||
channel_member_count: 999,
|
||||
channel_member_timezones_count: 131,
|
||||
},
|
||||
]);
|
||||
|
||||
await store.dispatch(Actions.getChannelMemberCountsByGroup(channelID, true));
|
||||
|
||||
const channelMemberCounts = store.getState().entities.channels.channelMemberCountsByGroup[channelID];
|
||||
assert.equal(channelMemberCounts['group-1'].group_id, 'group-1');
|
||||
assert.equal(channelMemberCounts['group-1'].channel_member_count, 1);
|
||||
assert.equal(channelMemberCounts['group-1'].channel_member_timezones_count, 1);
|
||||
|
||||
assert.equal(channelMemberCounts['group-2'].group_id, 'group-2');
|
||||
assert.equal(channelMemberCounts['group-2'].channel_member_count, 999);
|
||||
assert.equal(channelMemberCounts['group-2'].channel_member_timezones_count, 131);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {logError} from './errors';
|
||||
import {bindClientFunc, forceLogoutIfNecessary} from './helpers';
|
||||
import {getMissingProfilesByIds} from './users';
|
||||
import {loadRolesIfNeeded} from './roles';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
|
||||
export function selectChannel(channelId: string) {
|
||||
return {
|
||||
@@ -626,7 +627,7 @@ export function leaveChannel(channelId: string): ActionFunc {
|
||||
const channel = channels[channelId];
|
||||
const member = myMembers[channelId];
|
||||
|
||||
Client4.trackEvent('action', 'action_channels_leave', {channel_id: channelId});
|
||||
analytics.trackAction('action_channels_leave', {channel_id: channelId});
|
||||
|
||||
dispatch({
|
||||
type: ChannelTypes.LEAVE_CHANNEL,
|
||||
@@ -679,7 +680,7 @@ export function joinChannel(userId: string, teamId: string, channelId: string, c
|
||||
return {error};
|
||||
}
|
||||
|
||||
Client4.trackEvent('action', 'action_channels_join', {channel_id: channelId});
|
||||
analytics.trackAction('action_channels_join', {channel_id: channelId});
|
||||
|
||||
dispatch(batchActions([
|
||||
{
|
||||
@@ -1127,7 +1128,7 @@ export function addChannelMember(channelId: string, userId: string, postRootId =
|
||||
return {error};
|
||||
}
|
||||
|
||||
Client4.trackEvent('action', 'action_channels_add_member', {channel_id: channelId});
|
||||
analytics.trackAction('action_channels_add_member', {channel_id: channelId});
|
||||
|
||||
dispatch(batchActions([
|
||||
{
|
||||
@@ -1158,7 +1159,7 @@ export function removeChannelMember(channelId: string, userId: string): ActionFu
|
||||
return {error};
|
||||
}
|
||||
|
||||
Client4.trackEvent('action', 'action_channels_remove_member', {channel_id: channelId});
|
||||
analytics.trackAction('action_channels_remove_member', {channel_id: channelId});
|
||||
|
||||
dispatch(batchActions([
|
||||
{
|
||||
@@ -1199,7 +1200,7 @@ export function updateChannelMemberRoles(channelId: string, userId: string, role
|
||||
|
||||
export function updateChannelHeader(channelId: string, header: string): ActionFunc {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
Client4.trackEvent('action', 'action_channels_update_header', {channel_id: channelId});
|
||||
analytics.trackAction('action_channels_update_header', {channel_id: channelId});
|
||||
|
||||
dispatch({
|
||||
type: ChannelTypes.UPDATE_CHANNEL_HEADER,
|
||||
@@ -1215,7 +1216,7 @@ export function updateChannelHeader(channelId: string, header: string): ActionFu
|
||||
|
||||
export function updateChannelPurpose(channelId: string, purpose: string): ActionFunc {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
Client4.trackEvent('action', 'action_channels_update_purpose', {channel_id: channelId});
|
||||
analytics.trackAction('action_channels_update_purpose', {channel_id: channelId});
|
||||
|
||||
dispatch({
|
||||
type: ChannelTypes.UPDATE_CHANNEL_PURPOSE,
|
||||
@@ -1394,7 +1395,7 @@ export function favoriteChannel(channelId: string): ActionFunc {
|
||||
value: 'true',
|
||||
};
|
||||
|
||||
Client4.trackEvent('action', 'action_channels_favorite');
|
||||
analytics.trackAction('action_channels_favorite');
|
||||
|
||||
return dispatch(savePreferences(currentUserId, [preference]));
|
||||
};
|
||||
@@ -1410,7 +1411,7 @@ export function unfavoriteChannel(channelId: string): ActionFunc {
|
||||
value: '',
|
||||
};
|
||||
|
||||
Client4.trackEvent('action', 'action_channels_unfavorite');
|
||||
analytics.trackAction('action_channels_unfavorite');
|
||||
|
||||
return deletePreferences(currentUserId, [preference])(dispatch, getState);
|
||||
};
|
||||
@@ -1475,6 +1476,26 @@ export function patchChannelModerations(channelId: string, patch: Array<ChannelM
|
||||
});
|
||||
}
|
||||
|
||||
export function getChannelMemberCountsByGroup(channelId: string, includeTimezones: boolean): ActionFunc {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
let channelMemberCountsByGroup;
|
||||
try {
|
||||
channelMemberCountsByGroup = await Client4.getChannelMemberCountsByGroup(channelId, includeTimezones);
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
if (channelMemberCountsByGroup.length) {
|
||||
dispatch({
|
||||
type: ChannelTypes.RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP,
|
||||
data: {channelId, memberCounts: channelMemberCountsByGroup},
|
||||
});
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
selectChannel,
|
||||
createChannel,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {GifTypes} from '@mm-redux/action_types';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import gfycatSdk from '@mm-redux/utils/gfycat_sdk';
|
||||
import {DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
|
||||
// APP PROPS
|
||||
|
||||
@@ -170,7 +170,7 @@ export function searchGfycat({searchText, count = 30, startIndex = 0}: { searchT
|
||||
const context = getState().entities.gifs.categories.tagsDict[searchText] ?
|
||||
'category' :
|
||||
'search';
|
||||
Client4.trackEvent(
|
||||
analytics.trackEvent(
|
||||
'gfycat',
|
||||
'views',
|
||||
{context, count: json.gfycats.length, keyword: searchText},
|
||||
@@ -204,7 +204,7 @@ export function searchCategory({tagName = '', gfyCount = 30, cursorPos = undefin
|
||||
dispatch(cacheGifsRequest(json.gfycats));
|
||||
dispatch(receiveCategorySearch({tagName, json}));
|
||||
|
||||
Client4.trackEvent(
|
||||
analytics.trackEvent(
|
||||
'gfycat',
|
||||
'views',
|
||||
{context: 'category', count: json.gfycats.length, keyword: tagName},
|
||||
|
||||
@@ -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<Array<GenericAction>> {
|
||||
if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 23)) {
|
||||
return [];
|
||||
}
|
||||
let knownUsers: Set<string>;
|
||||
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;
|
||||
|
||||
@@ -6,13 +6,14 @@ import {Client4} from '@mm-redux/client';
|
||||
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
|
||||
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
|
||||
import {batchActions, DispatchFunc, GetStateFunc, ActionFunc} from '@mm-redux/types/actions';
|
||||
|
||||
import {Command, DialogSubmission, IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mm-redux/types/integrations';
|
||||
|
||||
import {logError} from './errors';
|
||||
import {bindClientFunc, forceLogoutIfNecessary} from './helpers';
|
||||
import {bindClientFunc, forceLogoutIfNecessary, requestSuccess, requestFailure} from './helpers';
|
||||
export function createIncomingHook(hook: IncomingWebhook): ActionFunc {
|
||||
return bindClientFunc({
|
||||
clientFunc: Client4.createIncomingWebhook,
|
||||
@@ -174,6 +175,23 @@ export function getAutocompleteCommands(teamId: string, page = 0, perPage: numbe
|
||||
});
|
||||
}
|
||||
|
||||
export function getCommandAutocompleteSuggestions(userInput: string, teamId: string, commandArgs: any): ActionFunc {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
let data: any = null;
|
||||
try {
|
||||
analytics.trackCommand('get_suggestions_initiated', userInput);
|
||||
data = await Client4.getCommandAutocompleteSuggestionsList(userInput, teamId, commandArgs);
|
||||
} catch (error) {
|
||||
analytics.trackCommand('get_suggestions_failed', userInput, error.message);
|
||||
dispatch(batchActions([logError(error), requestFailure(IntegrationTypes.RECEIVED_COMMAND_SUGGESTIONS_FAILURE, error)]));
|
||||
return {error};
|
||||
}
|
||||
analytics.trackCommand('get_suggestions_success', userInput);
|
||||
dispatch(requestSuccess(IntegrationTypes.RECEIVED_COMMAND_SUGGESTIONS, data));
|
||||
return {data};
|
||||
};
|
||||
}
|
||||
|
||||
export function getCustomTeamCommands(teamId: string): ActionFunc {
|
||||
return bindClientFunc({
|
||||
clientFunc: Client4.getCustomTeamCommands,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {getMyChannelMember, markChannelAsUnread, markChannelAsRead, markChannelA
|
||||
import {getCustomEmojiByName, getCustomEmojisByName} from './emojis';
|
||||
import {logError} from './errors';
|
||||
import {forceLogoutIfNecessary} from './helpers';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
|
||||
import {
|
||||
deletePreferences,
|
||||
@@ -670,7 +671,7 @@ export function flagPost(postId: string) {
|
||||
value: 'true',
|
||||
};
|
||||
|
||||
Client4.trackEvent('action', 'action_posts_flag');
|
||||
analytics.trackAction('action_posts_flag');
|
||||
|
||||
return savePreferences(currentUserId, [preference])(dispatch);
|
||||
};
|
||||
@@ -1123,7 +1124,7 @@ export function unflagPost(postId: string) {
|
||||
name: postId,
|
||||
};
|
||||
|
||||
Client4.trackEvent('action', 'action_posts_unflag');
|
||||
analytics.trackAction('action_posts_unflag');
|
||||
|
||||
return deletePreferences(currentUserId, [preference])(dispatch, getState);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ import {ActionResult, batchActions, DispatchFunc, GetStateFunc, ActionFunc} from
|
||||
import {RelationOneToOne} from '@mm-redux/types/utilities';
|
||||
import {Post} from '@mm-redux/types/posts';
|
||||
import {SearchParameter} from '@mm-redux/types/search';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
|
||||
const WEBAPP_SEARCH_PER_PAGE = 20;
|
||||
export function getMissingChannelsFromPosts(posts: RelationOneToOne<Post, Post>): ActionFunc {
|
||||
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
|
||||
@@ -210,7 +212,7 @@ export function getRecentMentions(): ActionFunc {
|
||||
|
||||
const terms = termKeys.map(({key}) => key).join(' ').trim() + ' ';
|
||||
|
||||
Client4.trackEvent('api', 'api_posts_search_mention');
|
||||
analytics.trackAPI('api_posts_search_mention');
|
||||
posts = await Client4.searchPosts(teamId, terms, true);
|
||||
|
||||
const profilesAndStatuses = getProfilesAndStatusesForPosts(posts.posts, dispatch, getState);
|
||||
|
||||
@@ -22,6 +22,8 @@ import {logError} from './errors';
|
||||
import {bindClientFunc, forceLogoutIfNecessary, debounce} from './helpers';
|
||||
import {getMyPreferences, makeDirectChannelVisibleIfNecessary, makeGroupMessageVisibleIfNecessary} from './preferences';
|
||||
import {Dictionary} from '@mm-redux/types/utilities';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
|
||||
export function checkMfa(loginId: string): ActionFunc {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
dispatch({type: UserTypes.CHECK_MFA_REQUEST, data: null});
|
||||
@@ -126,8 +128,8 @@ function completeLogin(data: UserProfile): ActionFunc {
|
||||
data,
|
||||
});
|
||||
|
||||
Client4.setUserId(data.id);
|
||||
Client4.setUserRoles(data.roles);
|
||||
analytics.setUserId(data.id);
|
||||
analytics.setUserRoles(data.roles);
|
||||
let teamMembers;
|
||||
|
||||
try {
|
||||
@@ -231,11 +233,11 @@ export function loadMe(): ActionFunc {
|
||||
const {currentUserId} = getState().entities.users;
|
||||
const user = getState().entities.users.profiles[currentUserId];
|
||||
if (currentUserId) {
|
||||
Client4.setUserId(currentUserId);
|
||||
analytics.setUserId(currentUserId);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
Client4.setUserRoles(user.roles);
|
||||
analytics.setUserRoles(user.roles);
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -66,6 +67,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -101,6 +103,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -137,6 +140,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -171,6 +175,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -209,6 +214,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -244,6 +250,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -282,6 +289,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -316,6 +324,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -349,6 +358,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -381,6 +391,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -459,6 +470,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -504,6 +516,7 @@ describe('channels', () => {
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -550,6 +563,7 @@ describe('channels', () => {
|
||||
},
|
||||
}],
|
||||
},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
@@ -576,4 +590,121 @@ describe('channels', () => {
|
||||
expect(nextState.channelModerations.channel1[0].roles.guests).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP', () => {
|
||||
test('Should add new channel member counts', () => {
|
||||
const state = deepFreeze({
|
||||
channelsInTeam: {},
|
||||
currentChannelId: '',
|
||||
groupsAssociatedToChannel: {},
|
||||
myMembers: {},
|
||||
stats: {},
|
||||
totalCount: 0,
|
||||
membersInChannel: {},
|
||||
channels: {
|
||||
channel1: {
|
||||
id: 'channel1',
|
||||
team_id: 'team',
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
type: ChannelTypes.RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP,
|
||||
sync: true,
|
||||
currentChannelId: 'channel1',
|
||||
teamId: 'team',
|
||||
data: {
|
||||
channelId: 'channel1',
|
||||
memberCounts: [
|
||||
{
|
||||
group_id: 'group-1',
|
||||
channel_member_count: 1,
|
||||
channel_member_timezones_count: 1,
|
||||
},
|
||||
{
|
||||
group_id: 'group-2',
|
||||
channel_member_count: 999,
|
||||
channel_member_timezones_count: 131,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(nextState.channelMemberCountsByGroup.channel1['group-1'].channel_member_count).toEqual(1);
|
||||
expect(nextState.channelMemberCountsByGroup.channel1['group-1'].channel_member_timezones_count).toEqual(1);
|
||||
|
||||
expect(nextState.channelMemberCountsByGroup.channel1['group-2'].channel_member_count).toEqual(999);
|
||||
expect(nextState.channelMemberCountsByGroup.channel1['group-2'].channel_member_timezones_count).toEqual(131);
|
||||
});
|
||||
|
||||
test('Should replace existing channel member counts', () => {
|
||||
const state = deepFreeze({
|
||||
channelsInTeam: {},
|
||||
currentChannelId: '',
|
||||
groupsAssociatedToChannel: {},
|
||||
myMembers: {},
|
||||
stats: {},
|
||||
totalCount: 0,
|
||||
membersInChannel: {},
|
||||
channels: {
|
||||
channel1: {
|
||||
id: 'channel1',
|
||||
team_id: 'team',
|
||||
},
|
||||
},
|
||||
channelModerations: {},
|
||||
channelMemberCountsByGroup: {
|
||||
'group-1': {
|
||||
group_id: 'group-1',
|
||||
channel_member_count: 1,
|
||||
channel_member_timezones_count: 1,
|
||||
},
|
||||
'group-2': {
|
||||
group_id: 'group-2',
|
||||
channel_member_count: 999,
|
||||
channel_member_timezones_count: 131,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const nextState = channelsReducer(state, {
|
||||
type: ChannelTypes.RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP,
|
||||
sync: true,
|
||||
currentChannelId: 'channel1',
|
||||
teamId: 'team',
|
||||
data: {
|
||||
channelId: 'channel1',
|
||||
memberCounts: [
|
||||
{
|
||||
group_id: 'group-1',
|
||||
channel_member_count: 5,
|
||||
channel_member_timezones_count: 2,
|
||||
},
|
||||
{
|
||||
group_id: 'group-2',
|
||||
channel_member_count: 1002,
|
||||
channel_member_timezones_count: 133,
|
||||
},
|
||||
{
|
||||
group_id: 'group-3',
|
||||
channel_member_count: 12,
|
||||
channel_member_timezones_count: 13,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(nextState.channelMemberCountsByGroup.channel1['group-1'].channel_member_count).toEqual(5);
|
||||
expect(nextState.channelMemberCountsByGroup.channel1['group-1'].channel_member_timezones_count).toEqual(2);
|
||||
|
||||
expect(nextState.channelMemberCountsByGroup.channel1['group-2'].channel_member_count).toEqual(1002);
|
||||
expect(nextState.channelMemberCountsByGroup.channel1['group-2'].channel_member_timezones_count).toEqual(133);
|
||||
|
||||
expect(nextState.channelMemberCountsByGroup.channel1['group-3'].channel_member_count).toEqual(12);
|
||||
expect(nextState.channelMemberCountsByGroup.channel1['group-3'].channel_member_timezones_count).toEqual(13);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {combineReducers} from 'redux';
|
||||
import {ChannelTypes, UserTypes, SchemeTypes, GroupTypes} from '@mm-redux/action_types';
|
||||
import {General} from '../../constants';
|
||||
import {GenericAction} from '@mm-redux/types/actions';
|
||||
import {Channel, ChannelMembership, ChannelStats} from '@mm-redux/types/channels';
|
||||
import {Channel, ChannelMembership, ChannelStats, ChannelMemberCountByGroup, ChannelMemberCountsByGroup} from '@mm-redux/types/channels';
|
||||
import {RelationOneToMany, RelationOneToOne, IDMappedObjects, UserIDMappedObjects} from '@mm-redux/types/utilities';
|
||||
import {Team} from '@mm-redux/types/teams';
|
||||
|
||||
@@ -664,6 +664,33 @@ export function channelModerations(state: any = {}, action: GenericAction) {
|
||||
}
|
||||
}
|
||||
|
||||
export function channelMemberCountsByGroup(state: any = {}, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ChannelTypes.RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP: {
|
||||
const {channelId, memberCounts} = action.data;
|
||||
const memberCountsByGroup: ChannelMemberCountsByGroup = {};
|
||||
memberCounts.forEach((channelMemberCount: ChannelMemberCountByGroup) => {
|
||||
if (!state[channelId]?.[channelMemberCount.group_id] ||
|
||||
state[channelId]?.[channelMemberCount.group_id]?.channel_member_count !== channelMemberCount.channel_member_count ||
|
||||
state[channelId]?.[channelMemberCount.group_id]?.channel_member_timezones_count !== channelMemberCount.channel_member_timezones_count) {
|
||||
memberCountsByGroup[channelMemberCount.group_id] = channelMemberCount;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(memberCountsByGroup).length > 0) {
|
||||
return {
|
||||
...state,
|
||||
[channelId]: memberCountsByGroup,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
|
||||
// the current selected channel
|
||||
@@ -693,4 +720,7 @@ export default combineReducers({
|
||||
|
||||
// object where every key is the channel id and has an object with the channel moderations
|
||||
channelModerations,
|
||||
|
||||
// object where every key is the channel id containing one or several object(s) with a mapping of <group_id: ChannelMemberCountByGroup>
|
||||
channelMemberCountsByGroup,
|
||||
});
|
||||
|
||||
@@ -163,6 +163,20 @@ function syncables(state: Dictionary<GroupSyncables> = {}, action: GenericAction
|
||||
}
|
||||
}
|
||||
|
||||
function myGroups(state: any = {}, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case GroupTypes.RECEIVED_MY_GROUPS: {
|
||||
const nextState = {...state};
|
||||
for (const group of action.data) {
|
||||
nextState[group.id] = group;
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function members(state: any = {}, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case GroupTypes.RECEIVED_GROUP_MEMBERS: {
|
||||
@@ -221,6 +235,7 @@ function groups(state: Dictionary<Group> = {}, action: GenericAction) {
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
myGroups,
|
||||
syncables,
|
||||
members,
|
||||
groups,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import {combineReducers} from 'redux';
|
||||
import {IntegrationTypes, ChannelTypes} from '@mm-redux/action_types';
|
||||
import {GenericAction} from '@mm-redux/types/actions';
|
||||
import {Command, IncomingWebhook, OutgoingWebhook, OAuthApp} from '@mm-redux/types/integrations';
|
||||
import {Command, IncomingWebhook, OutgoingWebhook, OAuthApp, AutocompleteSuggestion} from '@mm-redux/types/integrations';
|
||||
import {Dictionary, IDMappedObjects} from '@mm-redux/types/utilities';
|
||||
|
||||
function incomingHooks(state: IDMappedObjects<IncomingWebhook> = {}, action: GenericAction) {
|
||||
@@ -159,6 +159,17 @@ function systemCommands(state: IDMappedObjects<Command> = {}, action: GenericAct
|
||||
}
|
||||
}
|
||||
|
||||
function commandAutocompleteSuggestions(state: Array<AutocompleteSuggestion> = [], action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case IntegrationTypes.RECEIVED_COMMAND_SUGGESTIONS:
|
||||
return action.data;
|
||||
case IntegrationTypes.RECEIVED_COMMAND_SUGGESTIONS_FAILURE:
|
||||
return [];
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function oauthApps(state: IDMappedObjects<OAuthApp> = {}, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case IntegrationTypes.RECEIVED_OAUTH_APPS: {
|
||||
@@ -224,4 +235,7 @@ export default combineReducers({
|
||||
|
||||
// data for an interactive dialog to display
|
||||
dialog,
|
||||
|
||||
// object represents slash command autocomplete suggestions
|
||||
commandAutocompleteSuggestions,
|
||||
});
|
||||
|
||||
@@ -3811,3 +3811,32 @@ test('Selectors.Channels.getChannelModerations', () => {
|
||||
assert.equal(Selectors.getChannelModerations(state, undefined), undefined);
|
||||
assert.equal(Selectors.getChannelModerations(state, 'undefined'), undefined);
|
||||
});
|
||||
|
||||
test('Selectors.Channels.getChannelMemberCountsByGroup', () => {
|
||||
const memberCounts = {
|
||||
'group-1': {
|
||||
group_id: 'group-1',
|
||||
channel_member_count: 1,
|
||||
channel_member_timezones_count: 1,
|
||||
},
|
||||
'group-2': {
|
||||
group_id: 'group-2',
|
||||
channel_member_count: 999,
|
||||
channel_member_timezones_count: 131,
|
||||
},
|
||||
};
|
||||
|
||||
const state = {
|
||||
entities: {
|
||||
channels: {
|
||||
channelMemberCountsByGroup: {
|
||||
channel1: memberCounts,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(Selectors.getChannelMemberCountsByGroup(state, 'channel1'), memberCounts);
|
||||
assert.deepEqual(Selectors.getChannelMemberCountsByGroup(state, undefined), {});
|
||||
assert.deepEqual(Selectors.getChannelMemberCountsByGroup(state, 'undefined'), {});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import {createIdsSelector} from '@mm-redux/utils/helpers';
|
||||
|
||||
export {getCurrentChannelId, getMyChannelMemberships, getMyCurrentChannelMembership};
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {Channel, ChannelStats, ChannelMembership, ChannelModeration} from '@mm-redux/types/channels';
|
||||
import {Channel, ChannelStats, ChannelMembership, ChannelModeration, ChannelMemberCountsByGroup} from '@mm-redux/types/channels';
|
||||
import {UsersState, UserProfile} from '@mm-redux/types/users';
|
||||
import {PreferenceType} from '@mm-redux/types/preferences';
|
||||
import {Post} from '@mm-redux/types/posts';
|
||||
@@ -930,3 +930,7 @@ export function isManuallyUnread(state: GlobalState, channelId?: string): boolea
|
||||
export function getChannelModerations(state: GlobalState, channelId: string): Array<ChannelModeration> {
|
||||
return state.entities.channels.channelModerations[channelId];
|
||||
}
|
||||
|
||||
export function getChannelMemberCountsByGroup(state: GlobalState, channelId: string): ChannelMemberCountsByGroup {
|
||||
return state.entities.channels.channelMemberCountsByGroup[channelId] || {};
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
// See LICENSE.txt for license information.
|
||||
import * as reselect from 'reselect';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {Dictionary} from '@mm-redux/types/utilities';
|
||||
import {Group} from '@mm-redux/types/groups';
|
||||
import {filterGroupsMatchingTerm} from '@mm-redux/utils/group_utils';
|
||||
import {getCurrentUserLocale} from '@mm-redux/selectors/entities/i18n';
|
||||
import {getChannel} from '@mm-redux/selectors/entities/channels';
|
||||
import {UserMentionKey} from '@mm-redux/selectors/entities/users';
|
||||
import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles';
|
||||
import {getTeam} from '@mm-redux/selectors/entities/teams';
|
||||
import {Permissions} from '@mm-redux/constants';
|
||||
@@ -17,7 +19,11 @@ const emptySyncables = {
|
||||
};
|
||||
|
||||
export function getAllGroups(state: GlobalState) {
|
||||
return state.entities.groups?.groups || [];
|
||||
return state.entities.groups?.groups || {};
|
||||
}
|
||||
|
||||
export function getMyGroups(state: GlobalState) {
|
||||
return state.entities.groups?.myGroups || {};
|
||||
}
|
||||
|
||||
export function getGroup(state: GlobalState, id: string) {
|
||||
@@ -91,6 +97,13 @@ export function getAssociatedGroupsForReference(state: GlobalState, teamId: stri
|
||||
return groupsForReference.sort((groupA: Group, groupB: Group) => groupA.name.localeCompare(groupB.name, locale));
|
||||
}
|
||||
|
||||
export const getAssociatedGroupsForReferenceMap = reselect.createSelector(
|
||||
getAssociatedGroupsForReference,
|
||||
(allGroups) => {
|
||||
return new Map(allGroups.map((group) => [`@${group.name}`, group]));
|
||||
},
|
||||
);
|
||||
|
||||
const teamGroupIDs = (state: GlobalState, teamID: string) => state.entities.teams.groupsAssociatedToTeam[teamID]?.ids || [];
|
||||
|
||||
const channelGroupIDs = (state: GlobalState, channelID: string) => state.entities.channels.groupsAssociatedToChannel[channelID]?.ids || [];
|
||||
@@ -159,3 +172,32 @@ export const getAllAssociatedGroupsForReference = reselect.createSelector(
|
||||
return Object.values(allGroups).filter((group) => group.allow_reference && group.delete_at === 0);
|
||||
},
|
||||
);
|
||||
|
||||
export const getMyAllowReferencedGroups = reselect.createSelector(
|
||||
getMyGroups,
|
||||
(myGroups) => {
|
||||
return Object.values(myGroups).filter((group) => group.allow_reference && group.delete_at === 0);
|
||||
},
|
||||
);
|
||||
|
||||
export const getCurrentUserGroupMentionKeys = reselect.createSelector(
|
||||
getMyAllowReferencedGroups,
|
||||
(groups: Array<Group>) => {
|
||||
const keys: UserMentionKey[] = [];
|
||||
groups.forEach((group) => keys.push({key: `@${group.name}`}));
|
||||
return keys;
|
||||
},
|
||||
);
|
||||
|
||||
export const getGroupsByName = reselect.createSelector(
|
||||
getAllGroups,
|
||||
(groups) => {
|
||||
const groupsByName: Dictionary<Group> = {};
|
||||
|
||||
Object.values(groups).forEach((group) => {
|
||||
groupsByName[group.name] = group;
|
||||
});
|
||||
|
||||
return groupsByName;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -26,6 +26,10 @@ export function getSystemCommands(state: types.store.GlobalState) {
|
||||
return state.entities.integrations.systemCommands;
|
||||
}
|
||||
|
||||
export function getCommandAutocompleteSuggestionsList(state: types.store.GlobalState) {
|
||||
return state.entities.integrations.commandAutocompleteSuggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* get outgoing hooks in current team
|
||||
*/
|
||||
|
||||
@@ -26,4 +26,184 @@ describe('Selectors.Search', () => {
|
||||
it('should return current search for current team', () => {
|
||||
assert.deepEqual(Selectors.getCurrentSearchForCurrentTeam(testState), team1CurrentSearch);
|
||||
});
|
||||
|
||||
it('getAllUserMentionKeys', () => {
|
||||
const userId = '1234';
|
||||
const notifyProps = {
|
||||
first_name: 'true',
|
||||
};
|
||||
const state = {
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: userId,
|
||||
profiles: {
|
||||
[userId]: {id: userId, username: 'user', first_name: 'First', last_name: 'Last', notify_props: notifyProps},
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
myGroups: {
|
||||
test1: {
|
||||
name: 'I-AM-THE-BEST!',
|
||||
delete_at: 0,
|
||||
allow_reference: true,
|
||||
},
|
||||
test2: {
|
||||
name: 'Do-you-love-me?',
|
||||
delete_at: 0,
|
||||
allow_reference: true,
|
||||
},
|
||||
test3: {
|
||||
name: 'Maybe?-A-little-bit-I-guess....',
|
||||
delete_at: 0,
|
||||
allow_reference: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(Selectors.getAllUserMentionKeys(state), [{key: 'First', caseSensitive: true}, {key: '@user'}, {key: '@I-AM-THE-BEST!'}, {key: '@Do-you-love-me?'}]);
|
||||
});
|
||||
|
||||
describe('makeGetMentionKeysForPost', () => {
|
||||
it('should return all mentionKeys', () => {
|
||||
const postProps = {
|
||||
disable_group_highlight: false,
|
||||
mentionHighlightDisabled: false,
|
||||
};
|
||||
const state = {
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'a123',
|
||||
profiles: {
|
||||
a123: {
|
||||
username: 'a123',
|
||||
notify_props: {
|
||||
channel: 'true',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
myGroups: {
|
||||
developers: {
|
||||
id: 123,
|
||||
name: 'developers',
|
||||
allow_reference: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const results = Selectors.makeGetMentionKeysForPost(state, postProps.disable_group_highlight, postProps.mentionHighlightDisabled);
|
||||
const expected = [{key: '@channel'}, {key: '@all'}, {key: '@here'}, {key: '@a123'}, {key: '@developers'}];
|
||||
assert.deepEqual(results, expected);
|
||||
});
|
||||
|
||||
it('should return mentionKeys without groups', () => {
|
||||
const postProps = {
|
||||
disable_group_highlight: true,
|
||||
mentionHighlightDisabled: false,
|
||||
};
|
||||
const state = {
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'a123',
|
||||
profiles: {
|
||||
a123: {
|
||||
username: 'a123',
|
||||
notify_props: {
|
||||
channel: 'true',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
myGroups: {
|
||||
developers: {
|
||||
id: 123,
|
||||
name: 'developers',
|
||||
allow_reference: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const results = Selectors.makeGetMentionKeysForPost(state, postProps.disable_group_highlight, postProps.mentionHighlightDisabled);
|
||||
const expected = [{key: '@channel'}, {key: '@all'}, {key: '@here'}, {key: '@a123'}];
|
||||
assert.deepEqual(results, expected);
|
||||
});
|
||||
|
||||
it('should return group mentions and all mentions without channel mentions', () => {
|
||||
const postProps = {
|
||||
disable_group_highlight: false,
|
||||
mentionHighlightDisabled: true,
|
||||
};
|
||||
const state = {
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'a123',
|
||||
profiles: {
|
||||
a123: {
|
||||
username: 'a123',
|
||||
notify_props: {
|
||||
channel: 'true',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
myGroups: {
|
||||
developers: {
|
||||
id: 123,
|
||||
name: 'developers',
|
||||
allow_reference: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const results = Selectors.makeGetMentionKeysForPost(state, postProps.disable_group_highlight, postProps.mentionHighlightDisabled);
|
||||
const expected = [{key: '@a123'}, {key: '@developers'}];
|
||||
assert.deepEqual(results, expected);
|
||||
});
|
||||
|
||||
it('should return all mentions without group mentions and channel mentions', () => {
|
||||
const postProps = {
|
||||
disable_group_highlight: true,
|
||||
mentionHighlightDisabled: true,
|
||||
};
|
||||
const state = {
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'a123',
|
||||
profiles: {
|
||||
a123: {
|
||||
username: 'a123',
|
||||
notify_props: {
|
||||
channel: 'true',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
myGroups: {
|
||||
developers: {
|
||||
id: 123,
|
||||
name: 'developers',
|
||||
allow_reference: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const results = Selectors.makeGetMentionKeysForPost(state, postProps.disable_group_highlight, postProps.mentionHighlightDisabled);
|
||||
const expected = [{key: '@a123'}];
|
||||
assert.deepEqual(results, expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,46 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import * as reselect from 'reselect';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {UserMentionKey} from './users';
|
||||
import {getCurrentUserMentionKeys} from '@mm-redux/selectors/entities/users';
|
||||
import {getCurrentUserGroupMentionKeys} from '@mm-redux/selectors/entities/groups';
|
||||
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
|
||||
import * as types from '@mm-redux/types';
|
||||
|
||||
export const getCurrentSearchForCurrentTeam = reselect.createSelector(
|
||||
(state: types.store.GlobalState) => state.entities.search.current,
|
||||
(state: GlobalState) => state.entities.search.current,
|
||||
getCurrentTeamId,
|
||||
(current, teamId) => {
|
||||
return current[teamId];
|
||||
},
|
||||
);
|
||||
|
||||
export const getAllUserMentionKeys: (state: GlobalState) => UserMentionKey[] = reselect.createSelector(
|
||||
getCurrentUserMentionKeys,
|
||||
(state: GlobalState) => getCurrentUserGroupMentionKeys(state),
|
||||
(userMentionKeys, groupMentionKeys) => {
|
||||
return userMentionKeys.concat(groupMentionKeys);
|
||||
},
|
||||
);
|
||||
|
||||
export const makeGetMentionKeysForPost: (state: GlobalState, disableGroupHighlight: boolean, mentionHighlightDisabled: boolean) => UserMentionKey[] = reselect.createSelector(
|
||||
getAllUserMentionKeys,
|
||||
getCurrentUserMentionKeys,
|
||||
(state: GlobalState, disableGroupHighlight: boolean) => disableGroupHighlight,
|
||||
(state: GlobalState, disableGroupHighlight: boolean, mentionHighlightDisabled: boolean) => mentionHighlightDisabled,
|
||||
(allMentionKeys, mentionKeysWithoutGroups, disableGroupHighlight = false, mentionHighlightDisabled = false) => {
|
||||
let mentionKeys = allMentionKeys;
|
||||
if (disableGroupHighlight) {
|
||||
mentionKeys = mentionKeysWithoutGroups;
|
||||
}
|
||||
|
||||
if (mentionHighlightDisabled) {
|
||||
const CHANNEL_MENTIONS = ['@all', '@channel', '@here'];
|
||||
mentionKeys = mentionKeys.filter((value) => !CHANNEL_MENTIONS.includes(value.key));
|
||||
}
|
||||
|
||||
return mentionKeys;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ export type ChannelsState = {
|
||||
totalCount: number;
|
||||
manuallyUnread: RelationOneToOne<Channel, boolean>;
|
||||
channelModerations: RelationOneToOne<Channel, Array<ChannelModeration>>;
|
||||
channelMemberCountsByGroup: RelationOneToOne<Channel, ChannelMemberCountsByGroup>;
|
||||
};
|
||||
|
||||
export type ChannelModeration = {
|
||||
@@ -98,3 +99,11 @@ export type ChannelModerationPatch = {
|
||||
members?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChannelMemberCountByGroup = {
|
||||
group_id: string;
|
||||
channel_member_count: number;
|
||||
channel_member_timezones_count: number;
|
||||
};
|
||||
|
||||
export type ChannelMemberCountsByGroup = Record<string, ChannelMemberCountByGroup>;
|
||||
|
||||
@@ -57,6 +57,9 @@ export type GroupsState = {
|
||||
groups: {
|
||||
[x: string]: Group;
|
||||
};
|
||||
myGroups: {
|
||||
[x: string]: Group;
|
||||
};
|
||||
};
|
||||
export type GroupSearchOpts = {
|
||||
q: string;
|
||||
|
||||
@@ -52,6 +52,15 @@ export type Command = {
|
||||
'description': string;
|
||||
'url': string;
|
||||
};
|
||||
|
||||
// AutocompleteSuggestion represents a single suggestion downloaded from the server.
|
||||
export type AutocompleteSuggestion = {
|
||||
Complete: string;
|
||||
Suggestion: string;
|
||||
Hint: string;
|
||||
Description: string;
|
||||
IconData: string;
|
||||
};
|
||||
export type OAuthApp = {
|
||||
'id': string;
|
||||
'creator_id': string;
|
||||
@@ -71,6 +80,7 @@ export type IntegrationsState = {
|
||||
oauthApps: IDMappedObjects<OAuthApp>;
|
||||
systemCommands: IDMappedObjects<Command>;
|
||||
commands: IDMappedObjects<Command>;
|
||||
commandAutocompleteSuggestions: Array<AutocompleteSuggestion>;
|
||||
};
|
||||
export type DialogSubmission = {
|
||||
url: string;
|
||||
|
||||
@@ -110,3 +110,8 @@ export type PostsState = {
|
||||
messagesHistory: MessageHistory;
|
||||
expandedURLs: Dictionary<string>;
|
||||
};
|
||||
|
||||
export type PostProps = {
|
||||
disable_group_highlight?: boolean;
|
||||
mentionHighlightDisabled: boolean;
|
||||
}
|
||||
|
||||
18
app/mm-redux/types/websocket.ts
Normal file
18
app/mm-redux/types/websocket.ts
Normal file
@@ -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<boolean>;
|
||||
user_id: string;
|
||||
channel_id: string;
|
||||
team_id: string;
|
||||
}
|
||||
|
||||
export type WebSocketMessage = {
|
||||
event: string;
|
||||
data: any;
|
||||
broadcast: WebsocketBroadcast;
|
||||
seq: number;
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export default class ChannelIOS extends ChannelBase {
|
||||
onChangeText={this.handleAutoComplete}
|
||||
cursorPositionEvent={CHANNEL_POST_TEXTBOX_CURSOR_CHANGE}
|
||||
valueEvent={CHANNEL_POST_TEXTBOX_VALUE_CHANGE}
|
||||
channelId={currentChannelId}
|
||||
/>
|
||||
</View>
|
||||
{LocalConfig.EnableMobileClientUpgrade && <ClientUpgradeListener/>}
|
||||
|
||||
@@ -118,6 +118,10 @@ export default class ChannelBase extends PureComponent {
|
||||
this.props.actions.recordLoadTime('Switch Team', 'teamSwitch');
|
||||
}
|
||||
|
||||
if (prevProps.isSupportedServer && !this.props.isSupportedServer) {
|
||||
unsupportedServer(this.props.isSystemAdmin, this.context.intl.formatMessage);
|
||||
}
|
||||
|
||||
if (this.props.theme !== prevProps.theme) {
|
||||
setNavigatorStyles(this.props.componentId, this.props.theme);
|
||||
EphemeralStore.allNavigationComponentIds.forEach((componentId) => {
|
||||
|
||||
@@ -102,7 +102,7 @@ export default class ChannelPostList extends PureComponent {
|
||||
|
||||
goToThread = (post) => {
|
||||
telemetry.start(['post_list:thread']);
|
||||
const {actions, channelId, registerTypingAnimation} = this.props;
|
||||
const {actions, channelId} = this.props;
|
||||
const rootId = (post.root_id || post.id);
|
||||
|
||||
Keyboard.dismiss();
|
||||
@@ -114,7 +114,6 @@ export default class ChannelPostList extends PureComponent {
|
||||
const passProps = {
|
||||
channelId,
|
||||
rootId,
|
||||
registerTypingAnimation,
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {recordLoadTime} from '@actions/views/root';
|
||||
import {selectDefaultTeam} from '@actions/views/select_team';
|
||||
import {ViewTypes} from '@constants';
|
||||
import {getChannelStats} from '@mm-redux/actions/channels';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
|
||||
import {getServerVersion} from '@mm-redux/selectors/entities/general';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
@@ -23,12 +24,17 @@ function mapStateToProps(state) {
|
||||
const currentTeam = getCurrentTeam(state);
|
||||
const roles = getCurrentUserId(state) ? getCurrentUserRoles(state) : '';
|
||||
const isSystemAdmin = checkIsSystemAdmin(roles);
|
||||
const isSupportedServer = isMinimumServerVersion(
|
||||
getServerVersion(state),
|
||||
ViewTypes.RequiredServer.MAJOR_VERSION,
|
||||
ViewTypes.RequiredServer.MIN_VERSION,
|
||||
ViewTypes.RequiredServer.PATCH_VERSION,
|
||||
);
|
||||
const serverVersion = Client4.getServerVersion() || getServerVersion(state);
|
||||
|
||||
let isSupportedServer = true;
|
||||
if (serverVersion) {
|
||||
isSupportedServer = isMinimumServerVersion(
|
||||
serverVersion,
|
||||
ViewTypes.RequiredServer.MAJOR_VERSION,
|
||||
ViewTypes.RequiredServer.MIN_VERSION,
|
||||
ViewTypes.RequiredServer.PATCH_VERSION,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
currentTeamId: currentTeam?.id,
|
||||
|
||||
@@ -276,7 +276,11 @@ export default class ChannelAddMembers extends PureComponent {
|
||||
const options = {not_in_channel_id: currentChannelId, team_id: currentTeamId, group_constrained: currentChannelGroupConstrained};
|
||||
this.setState({loading: true});
|
||||
|
||||
actions.searchProfiles(term.toLowerCase(), options).then(({data}) => {
|
||||
actions.searchProfiles(term.toLowerCase(), options).then((results) => {
|
||||
let data = [];
|
||||
if (results.data) {
|
||||
data = results.data;
|
||||
}
|
||||
this.setState({searchResults: data, loading: false});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -307,7 +307,11 @@ export default class ChannelMembers extends PureComponent {
|
||||
const options = {in_channel_id: currentChannelId};
|
||||
this.setState({loading: true});
|
||||
|
||||
actions.searchProfiles(term.toLowerCase(), options).then(({data}) => {
|
||||
actions.searchProfiles(term.toLowerCase(), options).then((results) => {
|
||||
let data = [];
|
||||
if (results.data) {
|
||||
data = results.data;
|
||||
}
|
||||
this.setState({searchResults: data, loading: false});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,71 +1,73 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`EditPost should match snapshot 1`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Connect(StatusBar) />
|
||||
<React.Fragment>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(61,60,64,0.03)",
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Connect(StatusBar) />
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderBottomColor": "rgba(61,60,64,0.1)",
|
||||
"borderBottomWidth": 1,
|
||||
"borderTopColor": "rgba(61,60,64,0.1)",
|
||||
"borderTopWidth": 1,
|
||||
"marginTop": 2,
|
||||
},
|
||||
null,
|
||||
Object {
|
||||
"height": NaN,
|
||||
},
|
||||
]
|
||||
Object {
|
||||
"backgroundColor": "rgba(61,60,64,0.03)",
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<TextInputWithLocalizedPlaceholder
|
||||
blurOnSubmit={false}
|
||||
disableFullscreenUI={true}
|
||||
keyboardAppearance="light"
|
||||
keyboardType="default"
|
||||
multiline={true}
|
||||
numberOfLines={10}
|
||||
onChangeText={[Function]}
|
||||
onSelectionChange={[Function]}
|
||||
placeholder={
|
||||
Object {
|
||||
"defaultMessage": "Edit the post...",
|
||||
"id": "edit_post.editPost",
|
||||
}
|
||||
}
|
||||
placeholderTextColor="rgba(61,60,64,0.4)"
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "#3d3c40",
|
||||
"fontSize": 14,
|
||||
"padding": 15,
|
||||
"textAlignVertical": "top",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderBottomColor": "rgba(61,60,64,0.1)",
|
||||
"borderBottomWidth": 1,
|
||||
"borderTopColor": "rgba(61,60,64,0.1)",
|
||||
"borderTopWidth": 1,
|
||||
"marginTop": 2,
|
||||
},
|
||||
null,
|
||||
Object {
|
||||
"height": NaN,
|
||||
},
|
||||
]
|
||||
}
|
||||
underlineColorAndroid="transparent"
|
||||
/>
|
||||
>
|
||||
<TextInputWithLocalizedPlaceholder
|
||||
blurOnSubmit={false}
|
||||
disableFullscreenUI={true}
|
||||
keyboardAppearance="light"
|
||||
keyboardType="default"
|
||||
multiline={true}
|
||||
numberOfLines={10}
|
||||
onChangeText={[Function]}
|
||||
onSelectionChange={[Function]}
|
||||
placeholder={
|
||||
Object {
|
||||
"defaultMessage": "Edit the post...",
|
||||
"id": "edit_post.editPost",
|
||||
}
|
||||
}
|
||||
placeholderTextColor="rgba(61,60,64,0.4)"
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "#3d3c40",
|
||||
"fontSize": 14,
|
||||
"padding": 15,
|
||||
"textAlignVertical": "top",
|
||||
},
|
||||
Object {
|
||||
"height": NaN,
|
||||
},
|
||||
]
|
||||
}
|
||||
underlineColorAndroid="transparent"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<KeyboardTrackingView
|
||||
@@ -87,7 +89,12 @@ exports[`EditPost should match snapshot 1`] = `
|
||||
nestedScrollEnabled={true}
|
||||
onChangeText={[Function]}
|
||||
onVisible={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"position": undefined,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</KeyboardTrackingView>
|
||||
</View>
|
||||
</React.Fragment>
|
||||
`;
|
||||
|
||||
@@ -248,27 +248,29 @@ export default class EditPost extends PureComponent {
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
<StatusBar/>
|
||||
<View style={style.scrollView}>
|
||||
{displayError}
|
||||
<View style={[inputContainerStyle, padding(isLandscape), {height}]}>
|
||||
<TextInputWithLocalizedPlaceholder
|
||||
ref={this.messageRef}
|
||||
value={message}
|
||||
blurOnSubmit={false}
|
||||
onChangeText={this.onPostChangeText}
|
||||
multiline={true}
|
||||
numberOfLines={10}
|
||||
style={[style.input, {height}]}
|
||||
placeholder={{id: t('edit_post.editPost'), defaultMessage: 'Edit the post...'}}
|
||||
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.4)}
|
||||
underlineColorAndroid='transparent'
|
||||
disableFullscreenUI={true}
|
||||
keyboardAppearance={getKeyboardAppearanceFromTheme(this.props.theme)}
|
||||
onSelectionChange={this.handleOnSelectionChange}
|
||||
keyboardType={this.state.keyboardType}
|
||||
/>
|
||||
<>
|
||||
<View style={style.container}>
|
||||
<StatusBar/>
|
||||
<View style={style.scrollView}>
|
||||
{displayError}
|
||||
<View style={[inputContainerStyle, padding(isLandscape), {height}]}>
|
||||
<TextInputWithLocalizedPlaceholder
|
||||
ref={this.messageRef}
|
||||
value={message}
|
||||
blurOnSubmit={false}
|
||||
onChangeText={this.onPostChangeText}
|
||||
multiline={true}
|
||||
numberOfLines={10}
|
||||
style={[style.input, {height}]}
|
||||
placeholder={{id: t('edit_post.editPost'), defaultMessage: 'Edit the post...'}}
|
||||
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.4)}
|
||||
underlineColorAndroid='transparent'
|
||||
disableFullscreenUI={true}
|
||||
keyboardAppearance={getKeyboardAppearanceFromTheme(this.props.theme)}
|
||||
onSelectionChange={this.handleOnSelectionChange}
|
||||
keyboardType={this.state.keyboardType}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<KeyboardTrackingView style={autocompleteStyles}>
|
||||
@@ -279,15 +281,19 @@ export default class EditPost extends PureComponent {
|
||||
value={message}
|
||||
nestedScrollEnabled={true}
|
||||
onVisible={this.onAutocompleteVisible}
|
||||
style={style.autocomplete}
|
||||
/>
|
||||
</KeyboardTrackingView>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
autocomplete: {
|
||||
position: undefined,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -279,20 +279,24 @@ export default class MoreDirectMessages extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
searchProfiles = (term) => {
|
||||
searchProfiles = async (term) => {
|
||||
const lowerCasedTerm = term.toLowerCase();
|
||||
const {actions, currentTeamId, restrictDirectMessage} = this.props;
|
||||
this.setState({loading: true});
|
||||
let results;
|
||||
|
||||
if (restrictDirectMessage) {
|
||||
actions.searchProfiles(lowerCasedTerm).then(({data}) => {
|
||||
this.setState({searchResults: data, loading: false});
|
||||
});
|
||||
results = await actions.searchProfiles(lowerCasedTerm);
|
||||
} else {
|
||||
actions.searchProfiles(lowerCasedTerm, {team_id: currentTeamId}).then(({data}) => {
|
||||
this.setState({searchResults: data, loading: false});
|
||||
});
|
||||
results = await actions.searchProfiles(lowerCasedTerm, {team_id: currentTeamId});
|
||||
}
|
||||
|
||||
let data = [];
|
||||
if (results.data) {
|
||||
data = results.data;
|
||||
}
|
||||
|
||||
this.setState({searchResults: data, loading: false});
|
||||
};
|
||||
|
||||
startConversation = async (selectedId) => {
|
||||
|
||||
@@ -362,7 +362,7 @@ export default class PostOptions extends PureComponent {
|
||||
closeButton: source,
|
||||
};
|
||||
|
||||
this.closeWithAnimation(() => showModal(screen, title, passProps));
|
||||
this.closeWithAnimation(() => showModal(screen, title, passProps, {modal: {swipeToDismiss: false}}));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user