Compare commits

...

35 Commits
v1 ... v1.34.0

Author SHA1 Message Date
Mattermost Build
20d7d87464 Bump app build number to 318 (#4661) (#4662)
(cherry picked from commit fbc400bcff)

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-08-11 12:18:36 -07:00
Mattermost Build
72c9414993 MM-27607 Fix filter undefined when searching profiles (#4657) (#4659)
(cherry picked from commit b001c50fdc)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-08-11 13:09:50 -04:00
Weblate (bot)
12dbcfe627 Translations update from Weblate (#4655)
* Translated using Weblate (German)

Currently translated at 98.7% (623 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/de/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/es/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/pt_BR/

Co-authored-by: Elisabeth Kulzer <elisabeth.kulzer@mattermost.com>
Co-authored-by: Elias  Nahum <elias@mattermost.com>
Co-authored-by: rodrigocorsi <rodrigocorsi@gmail.com>
2020-08-11 16:36:55 +02:00
Mattermost Build
5a019e7447 Bump app build number to 317 (#4652) (#4653)
(cherry picked from commit 9fe6a2f72c)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-08-07 13:59:18 -04:00
Miguel Alatzar
d6147d289b Check for undefined channel (#4650) 2020-08-07 10:35:50 -04:00
Mattermost Build
66d730387e Ignore pagination when loading group data (#4644) (#4651)
(cherry picked from commit 581e6ba12a)

Co-authored-by: Farhan Munshi <3207297+fmunshi@users.noreply.github.com>
2020-08-07 09:41:15 -04:00
Mattermost Build
798bb45782 [MM-27483] Fetch channel and member when loading from push notification (#4648) (#4649)
* Fetch channel and member

* Uncancel if unreadCount increased from 0

(cherry picked from commit 1084d38ecb)

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-08-06 14:40:29 -07:00
Elias Nahum
46d7a17d4a Fix edit post input text selection (#4647) 2020-08-06 17:23:53 -04:00
Mattermost Build
c6978c858a Cleanup fetch error & add details to sso (#4642) (#4643)
(cherry picked from commit bba139726d)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-08-04 14:02:15 -04:00
Weblate (bot)
bcec21d264 Translations update from Weblate (#4639)
* Translated using Weblate (Italian)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/it/

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/pt_BR/

Translated using Weblate (Korean)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/ko/

Translated using Weblate (Romanian)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/ro/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/ja/

Co-authored-by: Mattermost Weblate Notify Bot <dev-ops@mattermost.com>
Co-authored-by: kaakaa <stooner.hoe@gmail.com>
2020-08-03 21:46:09 -04:00
Saturnino Abril
235f26e7fd fix folder for build (#4634) 2020-08-04 00:21:03 +08:00
Mattermost Build
8c3184080d Bump app build number to 316 (#4630) (#4631)
(cherry picked from commit 086d1bddea)

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-07-31 08:42:02 -07:00
Miguel Alatzar
4992052fb0 Set results count to 0 (#4629) 2020-07-30 21:32:07 -07:00
Mattermost Build
e1ec0fdf94 Bump app build number to 315 (#4627) (#4628)
(cherry picked from commit dcc3c8031e)

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-07-30 13:24:24 -07:00
Mattermost Build
c8854ba51e [MM-27294] Allow returning to Channel screen without resetting the navigation root (#4619) (#4626)
* Add popDismissToChannel

* Use popToRoot + dismissAllModals

* Just dismissAllModals and popToRoot

* Revert unnecessary changes

* Close permalink on popToRoot

* Emit after dismissAllModals and popToRoot

(cherry picked from commit 1324bfd0bf)

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-07-30 12:59:42 -07:00
Mattermost Build
838a52221f Fix autocomplete after cache delete (#4623) (#4625)
(cherry picked from commit 9f98b943e7)

Co-authored-by: Shota Gvinepadze <wineson@gmail.com>
2020-07-30 15:51:24 -04:00
Miguel Alatzar
5ee6142142 Define registerTypingAnimation in Thread screen (#4617) 2020-07-27 16:39:16 -07:00
Weblate (bot)
f7848c9259 Translations update from Weblate (#4607)
* Translated using Weblate (Japanese)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/ja/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/ja/

* Translated using Weblate (Romanian)

Currently translated at 98.7% (623 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/ro/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.0% (625 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/pt_BR/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/zh_Hans/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/zh_Hant/

* Translated using Weblate (Korean)

Currently translated at 98.4% (621 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/ko/

* Translated using Weblate (Russian)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/ru/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/es/

* Translated using Weblate (French)

Currently translated at 98.4% (621 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/fr/

* Translated using Weblate (Polish)

Currently translated at 98.4% (621 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/pl/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/tr/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/tr/

* Translated using Weblate (Dutch)

Currently translated at 99.3% (627 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/nl/

* Translated using Weblate (Ukrainian)

Currently translated at 98.5% (622 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/uk/

* Translated using Weblate (German)

Currently translated at 98.7% (623 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/de/

* Translated using Weblate (Italian)

Currently translated at 99.3% (627 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/it/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/zh_Hant/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (631 of 631 strings)

Translation: mattermost-languages-shipped/mattermost-mobile_release-1.34
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile_release-1-34/nl/

Co-authored-by: Mattermost Weblate Notify Bot <dev-ops@mattermost.com>
Co-authored-by: kaakaa <stooner.hoe@gmail.com>
Co-authored-by: aeomin <lin@aeomin.net>
Co-authored-by: Chikei <chikei@gmail.com>
Co-authored-by: Alexey Napalkov <flynbit@gmail.com>
Co-authored-by: Elias  Nahum <elias@mattermost.com>
Co-authored-by: Kaya Zeren <kayazeren@gmail.com>
Co-authored-by: Tom De Moor <tom@controlaltdieliet.be>
2020-07-27 11:14:06 -04:00
Elias Nahum
977b385dc7 Show the unsupported alert if server version is available (#4608)
* Show the unsupported alert if server version is available

* Check for supported server version when channel screen updates
2020-07-27 11:13:21 -04:00
Miguel Alatzar
3e263e9119 Bump app build number to 314 (#4615) 2020-07-24 10:56:41 -07:00
Amy Blais
012bc45800 Update NOTICE.txt (#4611) 2020-07-24 10:32:24 -04:00
Mattermost Build
9b6b3d9bbc Automated cherry pick of #4609 (#4613)
* Fix the display the error message if ssoLogin action fails

* Fix typo

* Remove token parameter

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-07-23 19:40:36 -04:00
Elias Nahum
a6dd6e65ff MM-24895 Load team member and roles in the WebSocket event (#4603)
* Load team member and roles in the WebSocket event

* Split WebSocket actions and events into multiple files
2020-07-23 13:29:27 -04:00
Shota Gvinepadze
be2211a8cc Add analytics to the command autocomplete (#4597)
* Add analytics to the command autocomplete

* Refactor analytics

* Refactor reset method
2020-07-23 20:40:18 +04:00
Miguel Alatzar
f19701d704 Don't set state.inputValue in constructor (#4606) 2020-07-23 09:00:42 -07:00
Miguel Alatzar
2e3ff53988 Bump app build number to 313 (#4604) 2020-07-21 14:53:54 -07:00
Miguel Alatzar
a9a23f706a Remove simulator job (#4605) 2020-07-21 14:52:31 -07:00
Mattermost Build
59ed19cebd Bump app version number to 1.34.0 (#4601)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-07-21 13:10:42 -07:00
Miguel Alatzar
4dc929579b Bump app build number to 312 (#4602) 2020-07-21 12:21:49 -07:00
Mattermost Build
1326fb53f2 Allow to post attachment only messages (#4594)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-07-20 18:08:00 -04:00
Mattermost Build
aafc68a0e6 Automated cherry pick of #4579 (#4592)
* Implement non-cached autocomplete for mobile

* Add partial caching support

* Fix whitespace

* Implement suggestion fetching using actions

Co-authored-by: iomodo <wineson@gmail.com>
2020-07-20 16:57:28 -04:00
Mattermost Build
01c796e441 Automated cherry pick of #4551 (#4587)
* Initial Commit for Group Highlights

* Fix some stuff

* Get my groups

* update channel.js

* Address PR comments

* Address PR comments

* Update app/actions/views/channel.js

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>

* update selector stuff

Co-authored-by: Hossein Ahmadian-Yazdi <hyazdi1997@gmail.com>
Co-authored-by: Hossein Ahmadian-Yazdi <hahmadia@users.noreply.github.com>
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-07-20 10:30:38 -04:00
Mattermost Build
3f80957240 Rename actual_user_id to user_actual_id to be more consistent with the rest of MM telemetry. (#4586)
Co-authored-by: Alex Dovenmuehle <alex.dovenmuehle@mattermost.com>
2020-07-20 08:48:45 -04:00
Mattermost Build
cfdac2a3f9 We can allow events to RudderStack since we don't have any rate limit restrictions. (#4585)
Co-authored-by: Alex Dovenmuehle <alex.dovenmuehle@mattermost.com>
2020-07-20 08:48:29 -04:00
Hossein Ahmadian-Yazdi
fc815adaeb [MM 23785] Show confirmation dialogue when mention groups 2 (#4548) (#4583)
* Show Confirmation Dialogue WIP First Commit

Show Confirmation Dialogue WIP Second Commit

refactoring according to comments

refactor code according to comments

Fix linting problems

add i18n strings

Update regex pattern

add test and make fixes

fix message not submitting

Fix linting

fix index.js

fix conflicts

address PR comments

address PR comments

single dispatch

Address PR comments

add test

* Show Confirmation Dialogue WIP First Commit

Show Confirmation Dialogue WIP Second Commit

refactoring according to comments

refactor code according to comments

Fix linting problems

add i18n strings

Update regex pattern

add test and make fixes

fix message not submitting

Fix linting

fix index.js

fix conflicts

address PR comments

address PR comments

single dispatch

Address PR comments

add test

* make some changes

* fix test failures

* Address PR comments

* Update app/mm-redux/types/channels.ts

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Update app/mm-redux/selectors/entities/channels.ts

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Update app/mm-redux/selectors/entities/channels.test.js

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Update app/constants/autocomplete.js

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Address PR comments

* make group mention mapping its own function

* Address PR comments

* Update app/components/post_draft/post_draft.js

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Merge branch 'master' of https://github.com/mattermost/mattermost-mobile into MM-23785-ShowConfirmationDialogue-2

* Merge branch 'master' into MM-23785-ShowConfirmationDialogue-2

* Address MM-26987

* Retrieve group information on mount  RN: Group Mention confirmation prompt not shown on default channel load

* Update Regex to fix MM-26976

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-07-17 12:30:43 -04:00
129 changed files with 9868 additions and 7969 deletions

View File

@@ -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

View File

@@ -133,8 +133,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
versionCode 311
versionName "1.33.1"
versionCode 318
versionName "1.34.0"
multiDexEnabled = true
ndk {
abiFilters 'armeabi-v7a','arm64-v8a','x86','x86_64'

View File

@@ -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 = {

View File

@@ -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);
});
});

View File

@@ -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) {

View File

@@ -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);
};
}

View File

@@ -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

View 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);
});
});

View 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};
};
}

View 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');
});
});

View 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,
};
}

View 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};
};
}

View 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;
}
}

View 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);
});
});

View 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};
};
}

View 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);
});
});

View 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};
};
}

View 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};
}

View 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]);
});
});

View 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,
};
}

View 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,
};
}

View 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]);
});
});

View 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};
};
}

View 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');
});
});
});

View 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};
};
}

View 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);
});
});

View File

@@ -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

View File

@@ -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),
};
}

View File

@@ -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: [],

View File

@@ -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 (

View File

@@ -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,
},
]

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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}
/>
)

View File

@@ -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>
);

View File

@@ -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>
`;

View File

@@ -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,
},

View File

@@ -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>

View File

@@ -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),
};

View File

@@ -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');

View File

@@ -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'}]);
});
});
});

View File

@@ -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),
};

View File

@@ -77,6 +77,13 @@ describe('makeMapStateToProps', () => {
general: {
serverVersion: '',
},
users: {
profiles: {},
},
groups: {
groups: {},
myGroups: {},
},
},
};
const defaultOwnProps = {

View File

@@ -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>
);

View File

@@ -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);

View File

@@ -37,14 +37,20 @@ describe('mapStateToProps', () => {
serverVersion: '',
},
users: {
profiles: {},
currentUserId: '',
},
channels: {
currentChannelId: '',
channelMemberCountsByGroup: {},
channels: {},
},
preferences: {
myPreferences: {},
},
teams: {
teams: {},
},
},
views: {
channel: {

View File

@@ -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

View 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: '@cha![](https://myimage)nnel',
result: false,
},
]) {
const wrapper = shallowWithIntl(
<PostDraft {...baseProps}/>,
);
const containsAtChannel = wrapper.instance().textContainsAtAllAtChannel(data.text);
assert.equal(containsAtChannel, data.result, data.text);
}
});
});

View File

@@ -41,7 +41,7 @@ export default class QuickActions extends PureComponent {
super(props);
this.state = {
inputValue: props.initialValue,
inputValue: '',
atDisabled: props.readonly,
slashDisabled: props.readonly,
};

View File

@@ -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 = {

View File

@@ -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);
}

View File

@@ -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);
});

View File

@@ -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();
}

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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();

View File

@@ -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);

View File

@@ -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,
});
}

View File

@@ -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');

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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},

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
};

View File

@@ -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);

View File

@@ -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

View File

@@ -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);
});
});
});

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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'), {});
});

View File

@@ -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] || {};
}

View File

@@ -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;
},
);

View File

@@ -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
*/

View File

@@ -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);
});
});
});

View File

@@ -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;
},
);

View File

@@ -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>;

View File

@@ -57,6 +57,9 @@ export type GroupsState = {
groups: {
[x: string]: Group;
};
myGroups: {
[x: string]: Group;
};
};
export type GroupSearchOpts = {
q: string;

View File

@@ -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;

View File

@@ -110,3 +110,8 @@ export type PostsState = {
messagesHistory: MessageHistory;
expandedURLs: Dictionary<string>;
};
export type PostProps = {
disable_group_highlight?: boolean;
mentionHighlightDisabled: boolean;
}

View 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;
}

View File

@@ -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/>}

View File

@@ -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) => {

View File

@@ -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(() => {

View File

@@ -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,

View File

@@ -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});
});
};

View File

@@ -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});
});
};

View File

@@ -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>
`;

View File

@@ -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,
},

View File

@@ -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) => {

View File

@@ -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}}));
});
};

View File

@@ -215,7 +215,11 @@ export default class SelectorScreen extends PureComponent {
const {actions} = this.props;
this.setState({loading: true});
actions.searchProfiles(term.toLowerCase()).then(({data}) => {
actions.searchProfiles(term.toLowerCase()).then((results) => {
let data = [];
if (results.data) {
data = results.data;
}
this.setState({searchResults: data, loading: false});
});
};

View File

@@ -119,7 +119,13 @@ class SSO extends PureComponent {
CookieManager.get(parsedUrl.href, true).then((res) => {
const mmtoken = res.MMAUTHTOKEN;
const csrf = res.MMCSRF;
const token = typeof mmtoken === 'object' ? mmtoken.value : mmtoken;
const csrfToken = typeof csrf === 'object' ? csrf.value : csrf;
if (csrfToken) {
Client4.setCSRF(csrfToken);
}
if (token) {
clearTimeout(this.cookiesTimeout);
@@ -129,7 +135,7 @@ class SSO extends PureComponent {
} = this.props.actions;
Client4.setToken(token);
ssoLogin(token).then((result) => {
ssoLogin().then((result) => {
if (result.error) {
this.onLoadEndError(result.error);
return;
@@ -204,7 +210,15 @@ class SSO extends PureComponent {
onLoadEndError = (e) => {
console.warn('Failed to set store from local data', e); // eslint-disable-line no-console
this.setState({error: e.message});
let error = e.message;
if (e.details) {
error += `\n${e.details.message}`;
}
if (e.url) {
error += `\nURL: ${e.url}`;
}
this.setState({error});
};
scheduleSessionExpiredNotification = () => {
@@ -227,15 +241,13 @@ class SSO extends PureComponent {
const style = getStyleSheet(theme);
let content;
if (!renderWebView) {
content = this.renderLoading();
} else if (error) {
if (error) {
content = (
<View style={style.errorContainer}>
<Text style={style.errorText}>{error}</Text>
</View>
);
} else {
} else if (renderWebView) {
content = (
<WebView
ref={this.webViewRef}
@@ -252,6 +264,8 @@ class SSO extends PureComponent {
cacheEnabled={false}
/>
);
} else {
content = this.renderLoading();
}
return (

Some files were not shown because too many files have changed in this diff Show More