forked from Ivasoft/mattermost-mobile
Compare commits
75 Commits
release-1.
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8fa73a52d | ||
|
|
32238fce9e | ||
|
|
234b7c9b62 | ||
|
|
40f61d6b2f | ||
|
|
f5b9583670 | ||
|
|
0134ab31b7 | ||
|
|
8d26937830 | ||
|
|
09172ac532 | ||
|
|
387b6aec2b | ||
|
|
e653a290e8 | ||
|
|
fcd57c81bf | ||
|
|
d82a0c6970 | ||
|
|
04a161f38c | ||
|
|
a34c7e3341 | ||
|
|
491c8e8a15 | ||
|
|
c77046b4ba | ||
|
|
fe5e702138 | ||
|
|
59a5ed746d | ||
|
|
59ad6d4ba9 | ||
|
|
e7a69afc65 | ||
|
|
b8e7fcaa21 | ||
|
|
be4c0401a6 | ||
|
|
d9cd23f68c | ||
|
|
9ab6b36e2f | ||
|
|
7378e116d6 | ||
|
|
62758221d4 | ||
|
|
6953801afa | ||
|
|
6ed2975f71 | ||
|
|
4ef1fb0eab | ||
|
|
b19d804c39 | ||
|
|
36228c89a1 | ||
|
|
79881556cf | ||
|
|
d5f6bf722b | ||
|
|
a730bec201 | ||
|
|
66424a698b | ||
|
|
162f4ffa96 | ||
|
|
14c937ee2c | ||
|
|
00ced38382 | ||
|
|
4f3e5dac1e | ||
|
|
49d9e5ef73 | ||
|
|
7b0434dcf9 | ||
|
|
6e4e8407ac | ||
|
|
dfc50b7dff | ||
|
|
4b58f01597 | ||
|
|
ae6e3dfb6d | ||
|
|
5a16752275 | ||
|
|
05f5b31815 | ||
|
|
a43a4d1c80 | ||
|
|
551456554d | ||
|
|
06a10b9b75 | ||
|
|
ec8c83c2eb | ||
|
|
bb8ebfba42 | ||
|
|
9661a61353 | ||
|
|
2a261249bb | ||
|
|
c4727e2492 | ||
|
|
591d65e57b | ||
|
|
8838fd92c2 | ||
|
|
9b08098572 | ||
|
|
dd0f424a63 | ||
|
|
04c660a2e8 | ||
|
|
b9c95a7953 | ||
|
|
99e6091014 | ||
|
|
3ba989f521 | ||
|
|
b7d4d592cc | ||
|
|
1111818d9f | ||
|
|
c1d3610e0d | ||
|
|
40102b0cbc | ||
|
|
3bd3e56025 | ||
|
|
07ee9f0cc9 | ||
|
|
68d7aad120 | ||
|
|
7fd1e26678 | ||
|
|
3d7acb2eb9 | ||
|
|
df5c1d73fe | ||
|
|
6020df0b38 | ||
|
|
fbf712dc8e |
@@ -1,6 +1,4 @@
|
||||
version: 2.1
|
||||
orbs:
|
||||
owasp: entur/owasp@0.0.10
|
||||
|
||||
executors:
|
||||
android:
|
||||
@@ -13,7 +11,7 @@ executors:
|
||||
NODE_ENV: production
|
||||
BABEL_ENV: production
|
||||
docker:
|
||||
- image: circleci/android:api-29-node
|
||||
- image: circleci/android:api-27-node
|
||||
working_directory: ~/mattermost-mobile
|
||||
resource_class: <<parameters.resource_class>>
|
||||
|
||||
@@ -23,7 +21,7 @@ executors:
|
||||
NODE_ENV: production
|
||||
BABEL_ENV: production
|
||||
macos:
|
||||
xcode: "12.0.0"
|
||||
xcode: "11.0.0"
|
||||
working_directory: ~/mattermost-mobile
|
||||
shell: /bin/bash --login -o pipefail
|
||||
|
||||
@@ -44,6 +42,7 @@ commands:
|
||||
for:
|
||||
type: string
|
||||
steps:
|
||||
- ruby-setup
|
||||
- restore_cache:
|
||||
name: Restore Fastlane cache
|
||||
key: v1-gems-<< parameters.for >>-{{ checksum "fastlane/Gemfile.lock" }}-{{ arch }}
|
||||
@@ -81,7 +80,7 @@ commands:
|
||||
key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }}
|
||||
- run:
|
||||
name: Generate assets
|
||||
command: node ./scripts/generate-assets.js
|
||||
command: make dist/assets
|
||||
- save_cache:
|
||||
name: Save assets cache
|
||||
key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }}
|
||||
@@ -103,8 +102,8 @@ commands:
|
||||
paths:
|
||||
- node_modules
|
||||
- run:
|
||||
name: "Patch dependencies"
|
||||
command: npx patch-package
|
||||
name: "Run post install scripts"
|
||||
command: make post-install
|
||||
|
||||
pods-dependencies:
|
||||
description: "Get cocoapods dependencies"
|
||||
@@ -112,12 +111,10 @@ commands:
|
||||
- restore_cache:
|
||||
name: Restore cocoapods specs and pods
|
||||
key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
|
||||
- run:
|
||||
name: iOS gems
|
||||
command: npm run ios-gems
|
||||
- run:
|
||||
name: Getting cocoapods dependencies
|
||||
command: npm run pod-install
|
||||
working_directory: ios
|
||||
command: pod install
|
||||
- save_cache:
|
||||
name: Save cocoapods specs and pods cache
|
||||
key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
|
||||
@@ -144,14 +141,11 @@ commands:
|
||||
echo MATTERMOST_RELEASE_STORE_FILE=${STORE_FILE} | tee -a android/gradle.properties > /dev/null
|
||||
echo ${STORE_ALIAS} | tee -a android/gradle.properties > /dev/null
|
||||
echo ${STORE_PASSWORD} | tee -a android/gradle.properties > /dev/null
|
||||
- run:
|
||||
name: Jetify android libraries
|
||||
command: ./node_modules/.bin/jetify
|
||||
- run:
|
||||
working_directory: fastlane
|
||||
name: Run fastlane to build android
|
||||
no_output_timeout: 30m
|
||||
command: export TERM=xterm && bundle exec fastlane android build
|
||||
command: bundle exec fastlane android build
|
||||
|
||||
build-ios:
|
||||
description: "Build the iOS app"
|
||||
@@ -169,7 +163,7 @@ commands:
|
||||
no_output_timeout: 30m
|
||||
command: |
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
|
||||
export TERM=xterm && bundle exec fastlane ios build
|
||||
bundle exec fastlane ios build
|
||||
|
||||
deploy-to-store:
|
||||
description: "Deploy build to store"
|
||||
@@ -202,13 +196,14 @@ commands:
|
||||
filename:
|
||||
type: string
|
||||
steps:
|
||||
- run:
|
||||
name: Copying artifacts
|
||||
command: |
|
||||
mkdir /tmp/artifacts;
|
||||
cp ~/mattermost-mobile/<<parameters.filename>> /tmp/artifacts;
|
||||
- store_artifacts:
|
||||
path: /tmp/artifacts
|
||||
path: ~/mattermost-mobile/<<parameters.filename>>
|
||||
|
||||
ruby-setup:
|
||||
steps:
|
||||
- run:
|
||||
name: Set Ruby Version
|
||||
command: echo "ruby-2.6.3" > ~/.ruby-version
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -228,59 +223,7 @@ jobs:
|
||||
command: npm test
|
||||
- run:
|
||||
name: Check i18n
|
||||
command: ./scripts/precommit/i18n.sh
|
||||
|
||||
check-deps:
|
||||
parameters:
|
||||
cve_data_directory:
|
||||
type: string
|
||||
default: "~/.owasp/dependency-check-data"
|
||||
working_directory: ~/mattermost-mobile
|
||||
executor: owasp/default
|
||||
environment:
|
||||
version_url: "https://jeremylong.github.io/DependencyCheck/current.txt"
|
||||
executable_url: "https://dl.bintray.com/jeremy-long/owasp/dependency-check-VERSION-release.zip"
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
name: Restore npm cache
|
||||
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
|
||||
- run:
|
||||
name: Checkout config
|
||||
command: cd .. && git clone https://github.com/mattermost/security-automation-config
|
||||
- run:
|
||||
name: Install Go
|
||||
command: sudo apt-get update && sudo apt-get install golang
|
||||
- owasp/with_commandline:
|
||||
steps:
|
||||
# Taken from https://github.com/entur/owasp-orb/blob/master/src/%40orb.yml#L349-L361
|
||||
- owasp/generate_cache_keys:
|
||||
cache_key: commmandline-default-cache-key-v6
|
||||
- owasp/restore_owasp_cache
|
||||
- run:
|
||||
name: Update OWASP Dependency-Check Database
|
||||
command: |
|
||||
if ! ~/.owasp/dependency-check/bin/dependency-check.sh --data << parameters.cve_data_directory >> --updateonly; then
|
||||
# Update failed, probably due to a bad DB version; delete cached DB and try again
|
||||
rm -rv ~/.owasp/dependency-check-data/*.db
|
||||
~/.owasp/dependency-check/bin/dependency-check.sh --data << parameters.cve_data_directory >> --updateonly
|
||||
fi
|
||||
- owasp/store_owasp_cache:
|
||||
cve_data_directory: <<parameters.cve_data_directory>>
|
||||
- run:
|
||||
name: Run OWASP Dependency-Check Analyzer
|
||||
command: |
|
||||
~/.owasp/dependency-check/bin/dependency-check.sh \
|
||||
--data << parameters.cve_data_directory >> --format ALL --noupdate --enableExperimental \
|
||||
--propertyfile ../security-automation-config/dependency-check/dependencycheck.properties \
|
||||
--suppression ../security-automation-config/dependency-check/suppression.xml \
|
||||
--suppression ../security-automation-config/dependency-check/suppression.$CIRCLE_PROJECT_REPONAME.xml \
|
||||
--scan './**/*' || true
|
||||
- owasp/collect_reports:
|
||||
persist_to_workspace: false
|
||||
- run:
|
||||
name: Post results to Mattermost
|
||||
command: go run ../security-automation-config/dependency-check/post_results.go
|
||||
command: make i18n-extract-ci
|
||||
|
||||
build-android-beta:
|
||||
executor: android
|
||||
@@ -288,7 +231,7 @@ jobs:
|
||||
- build-android
|
||||
- persist
|
||||
- save:
|
||||
filename: "*.apk"
|
||||
filename: "Mattermost_Beta.apk"
|
||||
|
||||
build-android-release:
|
||||
executor: android
|
||||
@@ -296,7 +239,7 @@ jobs:
|
||||
- build-android
|
||||
- persist
|
||||
- save:
|
||||
filename: "*.apk"
|
||||
filename: "Mattermost.apk"
|
||||
|
||||
build-android-pr:
|
||||
executor: android
|
||||
@@ -305,7 +248,7 @@ jobs:
|
||||
steps:
|
||||
- build-android
|
||||
- save:
|
||||
filename: "*.apk"
|
||||
filename: "Mattermost_Beta.apk"
|
||||
|
||||
build-android-unsigned:
|
||||
executor: android
|
||||
@@ -317,9 +260,6 @@ jobs:
|
||||
- fastlane-dependencies:
|
||||
for: android
|
||||
- gradle-dependencies
|
||||
- run:
|
||||
name: Jetify Android libraries
|
||||
command: ./node_modules/.bin/jetify
|
||||
- run:
|
||||
working_directory: fastlane
|
||||
name: Run fastlane to build unsigned android
|
||||
@@ -327,7 +267,7 @@ jobs:
|
||||
command: bundle exec fastlane android unsigned
|
||||
- persist
|
||||
- save:
|
||||
filename: "*.apk"
|
||||
filename: "Mattermost-unsigned.apk"
|
||||
|
||||
build-ios-beta:
|
||||
executor: ios
|
||||
@@ -335,7 +275,7 @@ jobs:
|
||||
- build-ios
|
||||
- persist
|
||||
- save:
|
||||
filename: "*.ipa"
|
||||
filename: "Mattermost_Beta.ipa"
|
||||
|
||||
build-ios-release:
|
||||
executor: ios
|
||||
@@ -343,7 +283,7 @@ jobs:
|
||||
- build-ios
|
||||
- persist
|
||||
- save:
|
||||
filename: "*.ipa"
|
||||
filename: "Mattermost.ipa"
|
||||
|
||||
build-ios-pr:
|
||||
executor: ios
|
||||
@@ -352,7 +292,7 @@ jobs:
|
||||
steps:
|
||||
- build-ios
|
||||
- save:
|
||||
filename: "*.ipa"
|
||||
filename: "Mattermost_Beta.ipa"
|
||||
|
||||
build-ios-unsigned:
|
||||
executor: ios
|
||||
@@ -368,75 +308,50 @@ jobs:
|
||||
working_directory: fastlane
|
||||
name: Run fastlane to build unsigned iOS
|
||||
no_output_timeout: 30m
|
||||
command: |
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
|
||||
bundle exec fastlane ios unsigned
|
||||
- persist_to_workspace:
|
||||
root: ~/
|
||||
paths:
|
||||
- mattermost-mobile/*.ipa
|
||||
command: bundle exec fastlane ios unsigned
|
||||
- persist
|
||||
- save:
|
||||
filename: "*.ipa"
|
||||
|
||||
build-ios-simulator:
|
||||
executor: ios
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/mattermost-mobile
|
||||
- npm-dependencies
|
||||
- pods-dependencies
|
||||
- assets
|
||||
- fastlane-dependencies:
|
||||
for: ios
|
||||
- run:
|
||||
working_directory: fastlane
|
||||
name: Run fastlane to build unsigned x86_64 iOS app for iPhone simulator
|
||||
no_output_timeout: 30m
|
||||
command: |
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
|
||||
bundle exec fastlane ios simulator
|
||||
- persist_to_workspace:
|
||||
root: ~/
|
||||
paths:
|
||||
- mattermost-mobile/Mattermost-simulator-x86_64.app.zip
|
||||
- save:
|
||||
filename: "Mattermost-simulator-x86_64.app.zip"
|
||||
filename: "Mattermost-unsigned.ipa"
|
||||
|
||||
deploy-android-release:
|
||||
executor:
|
||||
name: android
|
||||
resource_class: medium
|
||||
steps:
|
||||
- ruby-setup
|
||||
- deploy-to-store:
|
||||
task: "Deploy to Google Play"
|
||||
target: android
|
||||
file: "*.apk"
|
||||
file: Mattermost.apk
|
||||
|
||||
deploy-android-beta:
|
||||
executor:
|
||||
name: android
|
||||
resource_class: medium
|
||||
steps:
|
||||
- ruby-setup
|
||||
- deploy-to-store:
|
||||
task: "Deploy to Google Play"
|
||||
target: android
|
||||
file: "*.apk"
|
||||
file: Mattermost_Beta.apk
|
||||
|
||||
deploy-ios-release:
|
||||
executor: ios
|
||||
steps:
|
||||
- ruby-setup
|
||||
- deploy-to-store:
|
||||
task: "Deploy to TestFlight"
|
||||
target: ios
|
||||
file: "*.ipa"
|
||||
file: Mattermost.ipa
|
||||
|
||||
deploy-ios-beta:
|
||||
executor: ios
|
||||
steps:
|
||||
- ruby-setup
|
||||
- deploy-to-store:
|
||||
task: "Deploy to TestFlight"
|
||||
target: ios
|
||||
file: "*.ipa"
|
||||
file: Mattermost_Beta.ipa
|
||||
|
||||
github-release:
|
||||
executor:
|
||||
@@ -455,10 +370,6 @@ workflows:
|
||||
build:
|
||||
jobs:
|
||||
- test
|
||||
- check-deps:
|
||||
context: sast-webhook
|
||||
requires:
|
||||
- test
|
||||
|
||||
- build-android-release:
|
||||
context: mattermost-mobile-android-release
|
||||
@@ -567,7 +478,7 @@ workflows:
|
||||
tags:
|
||||
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
|
||||
branches:
|
||||
only: unsigned
|
||||
ignore: /.*/
|
||||
- build-ios-unsigned:
|
||||
context: mattermost-mobile-unsigned
|
||||
requires:
|
||||
@@ -576,18 +487,7 @@ workflows:
|
||||
tags:
|
||||
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
|
||||
branches:
|
||||
only: unsigned
|
||||
- build-ios-simulator:
|
||||
context: mattermost-mobile-unsigned
|
||||
requires:
|
||||
- test
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /^build-\d+$/
|
||||
- /^build-ios-\d+$/
|
||||
- /^build-ios-beta-\d+$/
|
||||
|
||||
ignore: /.*/
|
||||
- github-release:
|
||||
context: mattermost-mobile-unsigned
|
||||
requires:
|
||||
@@ -597,4 +497,4 @@ workflows:
|
||||
tags:
|
||||
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
|
||||
branches:
|
||||
only: unsigned
|
||||
ignore: /.*/
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"extends": [
|
||||
"plugin:mattermost/react",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"mattermost",
|
||||
"@typescript-eslint"
|
||||
"./node_modules/eslint-config-mattermost/.eslintrc.json",
|
||||
"./node_modules/eslint-config-mattermost/.eslintrc-react.json"
|
||||
],
|
||||
"settings": {
|
||||
"react": {
|
||||
@@ -23,29 +17,8 @@
|
||||
},
|
||||
"rules": {
|
||||
"global-require": 0,
|
||||
"no-undefined": 0,
|
||||
"react/display-name": [2, { "ignoreTranspilerName": false }],
|
||||
"react/jsx-filename-extension": 0,
|
||||
"camelcase": [
|
||||
0,
|
||||
{
|
||||
"properties": "never"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/ban-types": 0,
|
||||
"@typescript-eslint/no-non-null-assertion": 0,
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
2,
|
||||
{
|
||||
"vars": "all",
|
||||
"args": "after-used"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-use-before-define": 0,
|
||||
"@typescript-eslint/no-var-requires": 0,
|
||||
"@typescript-eslint/explicit-function-return-type": 0,
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off"
|
||||
"react/jsx-filename-extension": [2, {"extensions": [".js"]}]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
@@ -53,23 +26,6 @@
|
||||
"env": {
|
||||
"jest": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["detox/e2e/**"],
|
||||
"globals": {
|
||||
"by": true,
|
||||
"detox": true,
|
||||
"device": true,
|
||||
"element": true,
|
||||
"waitFor": true
|
||||
},
|
||||
"rules": {
|
||||
"func-names": 0,
|
||||
"import/no-unresolved": 0,
|
||||
"max-nested-callbacks": 0,
|
||||
"no-process-env": 0,
|
||||
"no-unused-expressions": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ node_modules/warning/.*
|
||||
[include]
|
||||
|
||||
[libs]
|
||||
node_modules/react-native/interface.js
|
||||
node_modules/react-native/Libraries/react-native/react-native-interface.js
|
||||
node_modules/react-native/flow/
|
||||
|
||||
[options]
|
||||
@@ -36,8 +36,9 @@ module.file_ext=.ios.js
|
||||
|
||||
munge_underscores=true
|
||||
|
||||
module.name_mapper='^react-native$' -> '<PROJECT_ROOT>/node_modules/react-native/Libraries/react-native/react-native-implementation'
|
||||
module.name_mapper='^react-native/\(.*\)$' -> '<PROJECT_ROOT>/node_modules/react-native/\1'
|
||||
module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> '<PROJECT_ROOT>/node_modules/react-native/Libraries/Image/RelativeImageStub'
|
||||
module.name_mapper='^[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> '<PROJECT_ROOT>/node_modules/react-native/Libraries/Image/RelativeImageStub'
|
||||
|
||||
suppress_type=$FlowIssue
|
||||
suppress_type=$FlowFixMe
|
||||
@@ -71,4 +72,4 @@ untyped-import
|
||||
untyped-type-import
|
||||
|
||||
[version]
|
||||
^0.122.0
|
||||
^0.105.0
|
||||
|
||||
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -1,4 +1 @@
|
||||
*.pbxproj -text
|
||||
|
||||
# specific for windows script files
|
||||
*.bat text eol=crlf
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -87,7 +87,6 @@ ios/sentry.properties
|
||||
# Testing
|
||||
.nyc_output
|
||||
coverage
|
||||
.tmp
|
||||
|
||||
# Bundle artifact
|
||||
*.jsbundle
|
||||
|
||||
551
CHANGELOG.md
551
CHANGELOG.md
@@ -1,556 +1,5 @@
|
||||
# Mattermost Mobile Apps Changelog
|
||||
|
||||
## 1.35.0 Release
|
||||
- Release Date: September 16, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Highlights
|
||||
|
||||
#### Upgrade to React Native 0.63.2
|
||||
- React Native 0.63.2 introduces performance and stability improvements to the core app platform.
|
||||
|
||||
### Improvements
|
||||
- Addded a default empty search state for the emoji picker screen.
|
||||
- Added an alert box to let users know what happened when removed from a channel they were viewing.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### All apps
|
||||
- Fixed an issue where the app crashed on a channel that had lot of images and attachments.
|
||||
- Fixed an issue where YouTube videos rendered as OpenGraph objects but also displayed play buttons when posted using bit.ly links.
|
||||
- Fixed an issue where at-mention notifications followed by a period were not highlighted.
|
||||
- Fixed an issue where the permission to delete other users' posts did not function independently of deleting own posts.
|
||||
- Fixed an issue where archiving a channel while in the permalink view cleared the permalink view content.
|
||||
- Fixed an issue where edits to “Full Name” in Mattermost profile got overwritten by the setting from the GitLab / Google / Office365 Single Sign-On providers.
|
||||
- Fixed an issue where an AD/LDAP group mention of an outsider group was highlighted on a Group Synced channel.
|
||||
|
||||
#### Android specific
|
||||
- Fixed an issue where users were unable to upload files with spaces in the file name.
|
||||
|
||||
#### iOS specific
|
||||
- Fixed an issue where using keyboard dictation sent a blank message.
|
||||
- Fixed an issue where users were unable to swipe to close the left-hand side after closing the keyboard.
|
||||
- Fixed an issue where the channel info screen ``This channel has guests`` text was out of safe area.
|
||||
|
||||
### Known Issues
|
||||
- Some Android devices running Android 11 may notice some choppiness in certain animations.
|
||||
|
||||
## 1.34.1 Release
|
||||
- Release Date: August 27, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where GitLab SSO was appending a # sign causing the app to fail on further requests.
|
||||
- Fixed an issue where an "Hair on fire" emoji caused the app to crash.
|
||||
- Fixed an issue where the app crashed when receiving a push notification when having special characters in the Nickname field.
|
||||
|
||||
## 1.34.0 Release
|
||||
- Release Date: August 16, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Highlights
|
||||
- End users will now receive an in-app notification to contact their System Admin to upgrade the server version if they are running versions v5.18 and below.
|
||||
- Added support for [LDAP group mentions (E20 feature)](https://docs.mattermost.com/deployment/ldap-group-sync.html) for mobile apps.
|
||||
- Added support for non-cached slash command autocomplete for mobile apps.
|
||||
|
||||
### Improvements
|
||||
- Removed auto-scrolling to the new message line on channel load and added a "More Messages" button when there are unread posts.
|
||||
- Improved screen styling for iOS Settings, Profile, Channel Info, "+" button for DMs and channels, Create Channel, and other user profile pages.
|
||||
- Added the ability to view users' first and last name in the profile view.
|
||||
- Added support on Android for showing a toast to exit when pressing the back button on channel screen.
|
||||
- Added the ability for editing others' posts to function independently of Edit Own Posts.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### All apps
|
||||
- Fixed an issue where an endless spinner instead of an error message was displayed when SSO login action failed.
|
||||
- Fixed an issue where users were unable to create channels when first joining a team.
|
||||
- Fixed an issue where an extra separator line appeared above the message box in landscape view after using mentions autocomplete.
|
||||
- Fixed an issue where the at-symbol was shown twice when clicking on the at-icon.
|
||||
|
||||
#### Android specific
|
||||
- Fixed an issue with keyboard glitches after using an invalid slash command.
|
||||
- Fixed an issue where the keyboard did not disappear when closing the channel sidebar **More** screen.
|
||||
- Fixed an issue where typing right after clicking the send button didn't clear the old message.
|
||||
|
||||
#### iOS specific
|
||||
- Fixed an issue where users were unable to edit a message that contained a bullet list.
|
||||
- Fixed an issue where user was unable to scroll or tap on emoji autocomplete in post **Edit** screen.
|
||||
- Fixed an issue where the channel list was not scrolled to the bottom when a new message was received while the keyboard was open.
|
||||
|
||||
## 1.33.1 Release
|
||||
- Release Date: July 15, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where the apps crashed when a malformed YouTube link was posted in a channel.
|
||||
|
||||
## 1.33.0 Release
|
||||
- Release Date: July 16, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Breaking Changes
|
||||
- Starting with mobile app v1.33.0, users on server versions below v5.19 may experience issues with how attachments, link previews, reactions and embed data are displayed. Updating your server to v5.19 or later is required.
|
||||
|
||||
**Note:** Mattermost Mobile App v1.33.0 contains a low level security fix. Upgrading is recommended. Details will be posted on our [security updates page](https://about.mattermost.com/security-updates/) 30 days after release as per the [Mattermost Responsible Disclosure Policy](https://www.mattermost.org/responsible-disclosure-policy/).
|
||||
|
||||
### Highlights
|
||||
- System admins will now receive an in-app notification to upgrade their server version if they are running versions v5.18 and below.
|
||||
|
||||
### Improvements
|
||||
- Removed **Select Team** title in cases where teams aren't loading.
|
||||
- The at-mention and search autocompletes now render even if there is a server request or a network outage.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### All apps
|
||||
- Fixed an issue where push notifications did not redirect to the correct channel when the app was not running in the background.
|
||||
- Fixed an issue where Enterprise mobility management (EMM) filled username field was not accepted as a valid username.
|
||||
- Fixed an issue where the app did not open on server url screen with previous server url filled in after logging out.
|
||||
- Fixed an issue where leaving a team in a browser while the mobile app was open caused the app to be stuck in the team.
|
||||
- Fixed an issue where, when hitting the **Delete Documents & Data** button, the button to join the team disappeared.
|
||||
- Fixed an issue where the channel header transition to landscape mode was slow.
|
||||
- Fixed an issue where teams were not listed alphabetically on the **Select Team** screen.
|
||||
- Fixed an issue where a currently active unread channel was not bolded.
|
||||
- Fixed an issue where a team icon was not visible on the left-hand side.
|
||||
- Fixed an issue where user was unable to create channels directly after joining a team.
|
||||
- Fixed an issue where the **:** search date picker on edit replaced the date and left old date info.
|
||||
- Fixed an issue where a confusing **Invalid Message** banner was present on Edit Message modal when typing a message that was over the character limit.
|
||||
- Fixed an issue with an unhandled error when logging out from the **Select Team** screen.
|
||||
- Fixed an issue where an error message on Server URL screen moved strangely when the keyboard slid on.
|
||||
- Fixed an issue with an uneven horizontal margins around **Jump to** box.
|
||||
- Fixed an issue where the OneLogin button had a blue outline, but a green fill.
|
||||
|
||||
#### Android specific
|
||||
- Fixed an issue where hitting edit multiple times opened the edit window without a save button.
|
||||
|
||||
#### iOS specific
|
||||
- Fixed an issue where ``switchKeyboardForCodeBlocks`` crashed the app on iOS 11.
|
||||
- Fixed an issue where the Enter key did not work in search when using an iPad with an external keyboard.
|
||||
- Fixed an issue where OAuth and SAML single sign-on (SSO) no longer required re-entering credentials after logging out and logging back in.
|
||||
|
||||
## 1.32.2 Release
|
||||
- Release Date: June 26, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where some users on the v1.32.0 or v1.32.1 mobile apps authenticating with GitLab or Office365 Single Sign-On (SSO) to a Mattermost server using a subpath were unable to login to the app. Some users authenticating to Mattermost using SAML SSO with two-factor authentication or authenticating to Mattermost with an SSO provider that utilizes query strings as part of the authentication URLs were also impacted.
|
||||
- Fixed an issue where opening the app was causing an "Unexpected Error" due to a failed migration.
|
||||
|
||||
## 1.32.1 Release
|
||||
- Release Date: June 25, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where Android app cold start and channel switching were slow.
|
||||
|
||||
## 1.32.0 Release
|
||||
- Release Date: June 16, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Breaking Changes
|
||||
- On mobile apps, users will not be able to see group mentions (E20 feature) in the autocomplete dropdown. Users will still receive notifications if they are part of an LDAP group. However, the group mention keyword will not be highlighted.
|
||||
- **Upcoming breaking change** Starting with mobile app v1.33.0 (to be released on July 16th), users on server versions below v5.19 may experience issues with how attachments, link previews, reactions and embed data are displayed. Updating your server to v5.19 or later is required.
|
||||
|
||||
### Highlights
|
||||
|
||||
#### Quick access to emoji reactions
|
||||
- Long press on a post and add recently used reactions in a single tap.
|
||||
|
||||
#### Upgrade to React Native 0.62
|
||||
- React Native 0.62 introduces performance and stability improvements to the core app platform.
|
||||
|
||||
### Improvements
|
||||
- Automatic retry when id-loaded push notification fails to fetch on receipt.
|
||||
- An appropriate error message is now shown when connecting to the server on the mobile app with an invalid SSL certificate.
|
||||
- Added the ability to find users by nickname when searching using ``@``.
|
||||
- Added the ability to view first and last name in profile view.
|
||||
- Improved the search bar to have smoother animations.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### All apps
|
||||
- Fixed an issue with an infinite skeleton channel screen on app relaunch when ``ExperimentalPrimaryTeam setting`` was enabled.
|
||||
- Fixed an issue where users were scrolled to old messages when switching to a channel with unread messages.
|
||||
- Fixed an issue where a logout message for session expiration was missing.
|
||||
- Fixed an issue where the app did not properly handle server URL and SSO redirects.
|
||||
- Fixed an issue where Direct and Group Messages disappeared from the left-hand side after opening them on webapp.
|
||||
- Fixed an issue where a crash occurred instead of showing proper error on entering invalid MFA token.
|
||||
- Fixed an issue where a user could not interact with the app until in-app notifications were dismissed.
|
||||
- Fixed an issue where using emoji on an instance with the custom emoji feature disabled triggered a "Custom emoji have been disabled by the system admin" error in the server logs.
|
||||
- Fixed an issue where the replay icon was cut off on full screen video preview.
|
||||
|
||||
#### Android specific
|
||||
- Fixed an issue where dropdowns in the channel modal were hard to read.
|
||||
|
||||
#### Known issues
|
||||
- Signing in with supported SSO methods (OKTA, OneLogin, GitLab and Office365) may fail to redirect on iOS 12. It is recommended to use iOS 13 if any issues are encountered.
|
||||
|
||||
## 1.31.2 Release
|
||||
- Release Date: May 27, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
Mattermost Mobile App v1.31.2 contains a high level security fix. [Upgrading](http://docs.mattermost.com/administration/upgrade.html) is recommended. Details will be posted on our [security updates page](https://about.mattermost.com/security-updates/) 30 days after release as per the [Mattermost Responsible Disclosure Policy](https://www.mattermost.org/responsible-disclosure-policy/).
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where file uploads failed due to a time out when the [Antivirus plugin](https://github.com/mattermost/mattermost-plugin-antivirus) was enabled.
|
||||
|
||||
## 1.31.1 Release
|
||||
- Release Date: May 22, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed a crash issue on Android when preloading images.
|
||||
|
||||
## 1.31.0 Release
|
||||
- Release Date: May 16, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Improvements
|
||||
- Improved network reliability and channel switching time for unread channels by fetching new posts as soon as the app reconnects.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### All apps
|
||||
- Fixed an issue where slash commands with long descriptions had their description text truncated in the slash command autocomplete.
|
||||
- Fixed an issue where users could not swipe up to dismiss in-app push notifications.
|
||||
- Fixed an issue where the username that created the webhook was shown on webhook posts instead of the name of the bot.
|
||||
- Fixed an issue where posts on the same thread appeared to be from different threads since the "...commented on [Thread Title]" was shown on all posts in the thread.
|
||||
- Fixed an issue where the system message for "Edit Channel Purpose" rendered markdown.
|
||||
|
||||
#### iOS specific
|
||||
- Fixed an issue where code block numbering was obstructed by the iPhone's notch.
|
||||
- Fixed an issue where the search text box was partially obstructed in landscape mode.
|
||||
- Fixed an issue where using `Share...` option to post highlighted text to the app threw an error.
|
||||
- Fixed an issue where the "back" button color was incorrect when transitioning from Thread screen to Channel screen.
|
||||
- Fixed an issue where the keyboard flashed a darker color when opening Keywords from **Settings > Notifications > Mentions and replies**.
|
||||
|
||||
#### Android specific
|
||||
- Fixed an issue where the keyboard did not close after editing a message.
|
||||
|
||||
## 1.30.1 Release
|
||||
- Release Date: April 24, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### All apps
|
||||
- Fixed an issue with repeated forced logouts.
|
||||
- Fixed an issue where channels appeared as read-only when opening the app.
|
||||
- Fixed an issue where users were unable to log in if ``ExperimentalStrictCSRFEnforcement`` setting was enabled.
|
||||
- A clean install may be required for the fix to take effect by uninstalling v1.30.0 (Build 285) and then installing v1.30.1 (Build 287).
|
||||
- Fixed an issue where a "No internet connection" error occurred when deleting documents and data.
|
||||
|
||||
#### iOS specific
|
||||
- Fixed an issue where Mattermost app crashed when Enterprise mobility management (EMM) was enabled.
|
||||
|
||||
#### Android specific
|
||||
- Fixed an issue where using backspace out of a conversation thread or a channel caused a forced logout.
|
||||
- Fixed an issue where a video upload attempt failed with an error.
|
||||
|
||||
## 1.30.0 Release
|
||||
- Release Date: April 16, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
Mattermost Mobile App v1.30.0 contains a high level security fix. [Upgrading](http://docs.mattermost.com/administration/upgrade.html) is recommended. Details will be posted on our [security updates page](https://about.mattermost.com/security-updates/) 30 days after release as per the [Mattermost Responsible Disclosure Policy](https://www.mattermost.org/responsible-disclosure-policy/).
|
||||
|
||||
**Note:** v5.9.0 as our Extended Support Release (ESR) is coming to the end of its lifecycle and upgrading to 5.19.0 ESR or a later version is highly recommended. v5.19.0 will continue to be our current ESR until October 15, 2020. [Learn more in our forum post](https://forum.mattermost.org/t/upcoming-extended-support-release-updates/8526).
|
||||
|
||||
**Note:** [The Channel Moderation Settings feature](https://docs.mattermost.com/deployment/advanced-permissions.html#channel-moderation-beta-e20) released in v5.22.0 is supported on mobile app versions v1.30 and later. In earlier versions of the mobile app, users who attempt to post or react to posts without proper permissions will see an error.
|
||||
|
||||
### Improvements
|
||||
- Significantly improved Android performance, including how quickly posts in the center screen are displayed.
|
||||
- Added support for different interactive message button styles on mobile.
|
||||
- Enter key on hardware Android keyboard now posts a message.
|
||||
- The statuses of those users that are in the Direct Message list are now fetched when opening the app and on login.
|
||||
- Added "Unarchive Channel" option to the channel info screen.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### All apps
|
||||
- Fixed an issue where the modal popped down when attempting to scroll down to see if there are more emoji.
|
||||
- Fixed a few crash issues.
|
||||
- Fixed an issue where the navigation bar tucked under status bar when using photo or camera post icons in landscape.
|
||||
- Removed mark as unread option from post menus for archived channels.
|
||||
- Fixed an issue where the "Refreshing message failed" error was shown when starting a Direct Message with a new user without a verified email.
|
||||
- Fixed an issue where Markdown tables was rendering in full in the center channel on larger screen sizes.
|
||||
- Made the name displayed consistent with teammate display name setting.
|
||||
- Fixed some selected emojis in autocomplete from rendering properly when posted.
|
||||
|
||||
#### iOS specific
|
||||
- Fixed an issue on iOS where the navigation bar tucked under status bar when using photo or camera post icons in landscape.
|
||||
- Fixed an issue on iOS where Automatic Replies custom message text box was obstructed by the iPhone's notch.
|
||||
- Fixed an issue on iOS where double dashes in mobile inside a code block got converted to emdash.
|
||||
|
||||
#### Android specific
|
||||
- Fixed an issue on Android where downloading a file or video was not reporting progress.
|
||||
- Fixed an issue on Android that was preventing to share content through the share extension.
|
||||
|
||||
## 1.29.0 Release
|
||||
- Release Date: March 16, 2020
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
**Note:** The persisted sidebar on Android tablets was removed in order to significantly improve the mobile app performance.
|
||||
|
||||
**Note:** An issue was fixed where a user's status was set as online when replying to a message from a push notification. This fix only works in combination with server v5.20.0+.
|
||||
|
||||
### Improvements
|
||||
- Significantly improved how quickly channels load when you open the app and when you switch between them.
|
||||
- Set all requests timeouts to a maximum of 5 seconds to improve reliability on bad networks.
|
||||
- Changed "Copy Permalink" to "Copy Link" for readability.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where downloaded files on Android had the words `download successful` appended to their filenames, preventing the file from being opened until it was renamed in the file manager.
|
||||
- Fixed a silent crash on Android when receiving a push notification.
|
||||
- Fixed an issue on Android where users could not swipe to close sidebar unless the gesture was initiated outside of the sidebar.
|
||||
- Fixed an issue where channels drawers were partially shown with orientation change on iOS RN61.
|
||||
- Fixed an issue on iOS where the message box obstructed the bottom part of the message when opened from the notification banner.
|
||||
- Fixed an issue where switching teams showed the center channel from the old team until the new team's channel data got loaded.
|
||||
- Fixed an issue where users could not post messages after returning from an archived channel.
|
||||
- Fixed an issue where user experienced infinite scrolling when viewing all public joinable/archived channels.
|
||||
- Fixed an issue where archived channels membership was lost on the client.
|
||||
- Fixed an issue on iOS where the channel intro scrolled past the top of the channel.
|
||||
- Fixed an issue on Android where inline custom emojis did not display in portrait mode.
|
||||
- Fixed an issue where markdown tables did not display all rows in a post when it had multiple heights.
|
||||
- Fixed an issue where deleting documents and data caused a flash of the background when the app reloaded.
|
||||
- Fixed an issue where tall and thin image attachments got pushed to the left instead of appearing centered.
|
||||
|
||||
### Known Issues
|
||||
- Some gender neutral emojis don't render as jumbo emojis.
|
||||
|
||||
## 1.28.0 Release
|
||||
- Release Date: February 16, 2020
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Highlights
|
||||
|
||||
#### UI/UX Improvements to the Post Draft Area
|
||||
- Links added to facilitate easier access to common functions:
|
||||
- finding channel members for @mentioning;
|
||||
- finding and referencing slash commands;
|
||||
- attaching photos and videos;
|
||||
- accessing the camera
|
||||
|
||||
#### Deep Linking
|
||||
- Links to posts in email notifications now launch to a browser landing page with option to open in the Mobile app.
|
||||
|
||||
### Improvements
|
||||
- Removed markdown rendering from Channel Purpose in channel info screen.
|
||||
- Improved channel info transition so that it opens up as a modal rather than as a drawer from the right.
|
||||
- Clicking on the time in the iOS status bar now scrolls up the center channel.
|
||||
- Improved the sliding behaviour of the left-hand sidebar on iOS.
|
||||
- Added more responsiveness to markdown tables.
|
||||
- User's own username with a suffix 'you' is now shown in the username autocomplete.
|
||||
- Improved sorting of emojis in the emoji picker so that thumbsup is sorted first, then thumbsdown, and then custom emoji.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue on Android where the app displayed an incorrect timestamp when the experimental Timezone setting was disabled.
|
||||
- Fixed an issue where combined system messages with many users listed hid posts above them.
|
||||
- Fixed an issue on iOS where the app crashed when pasting a GIF via the keyboard.
|
||||
- Fixed an issue where explicit links to teams and channels on the same server currently logged in to didn't switch to that team and channel.
|
||||
- Fixed an issue where the keyboard glitched when returning to the main channel view after viewing a code block in the right-hand side.
|
||||
- Fixed an issue with default boolean values in interactive dialogs.
|
||||
|
||||
### Known Issues
|
||||
- Markdown tables are missing a header colour.
|
||||
|
||||
## 1.27.1 Release
|
||||
- Release Date: January 21, 2020
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where all previously auto-closed Direct Message channels were listed in the channel sidebar.
|
||||
- Fixed a regression affecting webapp and mobile apps where some users were experiencing client-side performance issues. This was mainly affecting users with more than 100 channels listed in the channel sidebar and with channels sorted alphabetically.
|
||||
|
||||
## 1.27.0 Release
|
||||
- Release Date: January 16, 2020
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where flaky networks caused users to miss messages when at the top of the channel.
|
||||
- Fixed an issue where uploading image attachments in the mobile app was not working in some cases.
|
||||
- Fixed an issue where joining a user's first team from the mobile apps failed.
|
||||
- Fixed an issue where an unexpected `More New Messages Above` line appeared when marking a first post as unread in a Direct Message or Group Message channel.
|
||||
- Fixed an issue where disagreeing with custom Terms of Service gives users a glimpse of the app.
|
||||
- Fixed an issue on Android where the Back button did not dismiss the modal before dismissing the sidebar.
|
||||
- Fixed an issue where a message draft was lost after attempting to post an invalid slash command.
|
||||
- Fixed an issue where timestamps on 12-hour format had a leading zero.
|
||||
- Fixed an issue where the display name of a post was truncated even when there was enough space to render it on landscape.
|
||||
- Fixed an issue where the post input field icon was mis-aligned.
|
||||
- Fixed an issue where system message mentions were not at 100% opacity compared to non-system messages.
|
||||
|
||||
### Known Issues
|
||||
- Text box obstructs bottom part of messages in Direct Message channels when opened from a notification banner. [MM-21276](https://mattermost.atlassian.net/browse/MM-21276)
|
||||
|
||||
## 1.26.2 Release
|
||||
- Release Date: January 7, 2020
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue on iOS where the mobile app was not usable if ``inAppPincode`` was enabled.
|
||||
|
||||
## 1.26.1 Release
|
||||
- Release Date: December 20, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed a crash issue on Android and iOS on server versions prior to the v5.9.0 Extended Support Release (ESR).
|
||||
- Fixed a crash when connecting the WebSocket to a server with Cert Based Auth (CBA) enabled.
|
||||
|
||||
## 1.26.0 Release
|
||||
- Release Date: December 16, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgrading](http://docs.mattermost.com/administration/upgrade.html) is recommended. Details will be posted on our [security updates page](https://about.mattermost.com/security-updates/) 30 days after release as per the [Mattermost Responsible Disclosure Policy](https://www.mattermost.org/responsible-disclosure-policy/).
|
||||
|
||||
### Highlights
|
||||
|
||||
#### Improved Styling for File, Image and Video Attachments, Including In-line Image Thumbnails
|
||||
|
||||
#### Mark as Unread
|
||||
- With server v5.18 and above, users can stay on top of important messages with a new feature that allows marking posts as unread. After doing so, users will automatically land on the unread post the next time they click on the relevant channel.
|
||||
|
||||
#### Push Notification Message Contents Fetched from the Server on Receipt (E20)
|
||||
- Allows push notifications to be delivered showing the full message contents that are fetched from the server once the notification is delivered to the device. This means that Apple Push Notification Service (APNS) or Google Firebase Cloud Messaging (FCM) cannot read the message contents since only a unique message ID is sent in the notification payload.
|
||||
|
||||
#### Upgraded RN to v0.61
|
||||
|
||||
### Improvements
|
||||
- Added support for pasting other file types such as videos, PDFs and documents.
|
||||
- Added the option to convert public channels to private in the channel info screen.
|
||||
- Added support for reading the channel drawer button with voice-over.
|
||||
- Made usernames in system messages tappable.
|
||||
- Added an autocomplete to edit post screen.
|
||||
- Added a count for pinned posts icon.
|
||||
- Updated the channel name length character limit to 64 to match server.
|
||||
- Added an expand button to truncated markdown tables to improve discoverability of opening them in full screen.
|
||||
- Added an error message when trying to share too long text from share extension.
|
||||
- Improved behaviour where posts from different authors in the same thread appeared to be from different threads if separated by new message line.
|
||||
- Added support for native emojis in the emoji picker and autocomplete.
|
||||
- Removed reactions and file attachments from the long post view.
|
||||
- Large number of emoji reactions now wrap instead of introducing horizontal scroll.
|
||||
- Added support for a generic error message in interactive dialog responses.
|
||||
- Added the ability to disable attachment buttons and fields.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue on Android where the app slowed down when opening a channel with large number of animated emoji.
|
||||
- Fixed an issue where the app crashed when pasting a large file to the text box from the clipboard.
|
||||
- Fixed an issue where the app crashed when previewing large GIF files.
|
||||
- Fixed an issue where the app crashed when using the emoji category selector.
|
||||
- Fixed an issue where the app was not able to play YouTube videos.
|
||||
- Fixed an issue where images/videos could not be saved.
|
||||
- Fixed an issue where channels archived via the command line interface were still visible on the left-hand side and accessible on mobile apps.
|
||||
- Fixed an issue where the thread header in landscape view was wider than the main channel view header.
|
||||
- Fixed an issue where sidebar separator line was misaligned between Teams and Channel view.
|
||||
- Fixed an issue on iOS where the channel spinner appeared black on a dark theme.
|
||||
- Fixed an issue where an asterisk appeared on the "Nickname" and "Position" fields in Edit Profile screen even though nickname is not handled through the login provider.
|
||||
- Fixed an issue where the filtered list for emojis opened above the edit box and behind the channel header when adding an emoji to channel header using ``:emoji:``.
|
||||
|
||||
## 1.25.1 Release
|
||||
- Release Date: November 22, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed a crash issue on iOS when SSO cookies did not contain an expiration date during login.
|
||||
- Fixed a crash issue on Android caused by notification channels being unavailable in Android 7.
|
||||
- Fixed an issue on Android where Enterprise Mobility Management (EMM) blur app screen did not work.
|
||||
- Fixed an issue where changing team/channel when sharing several files closed the share dialog.
|
||||
|
||||
## 1.25.0 Release
|
||||
- Release Date: November 16, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
14
Jenkinsfile
vendored
Normal file
14
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
stages {
|
||||
stage('Test') {
|
||||
steps {
|
||||
echo 'assets/base/config.json'
|
||||
sh 'cat assets/base/config.json'
|
||||
sh 'touch .podinstall'
|
||||
sh 'make test || exit 1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
254
Makefile
Normal file
254
Makefile
Normal file
@@ -0,0 +1,254 @@
|
||||
.PHONY: pre-run pre-build clean
|
||||
.PHONY: check-style
|
||||
.PHONY: i18n-extract-ci
|
||||
.PHONY: start stop
|
||||
.PHONY: run run-ios run-android
|
||||
.PHONY: build build-ios build-android unsigned-ios unsigned-android ios-sim-x86_64
|
||||
.PHONY: build-pr can-build-pr prepare-pr
|
||||
.PHONY: test help
|
||||
|
||||
POD := $(shell which pod 2> /dev/null)
|
||||
OS := $(shell sh -c 'uname -s 2>/dev/null')
|
||||
BASE_ASSETS = $(shell find assets/base -type d) $(shell find assets/base -type f -name '*')
|
||||
OVERRIDE_ASSETS = $(shell find assets/override -type d 2> /dev/null) $(shell find assets/override -type f -name '*' 2> /dev/null)
|
||||
MM_UTILITIES_DIR = ../mattermost-utilities
|
||||
|
||||
node_modules: package.json
|
||||
@if ! [ $(shell which npm 2> /dev/null) ]; then \
|
||||
echo "npm is not installed https://npmjs.com"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
@echo Getting Javascript dependencies
|
||||
@npm install
|
||||
|
||||
npm-ci: package.json
|
||||
@if ! [ $(shell which npm 2> /dev/null) ]; then \
|
||||
echo "npm is not installed https://npmjs.com"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
@echo Getting Javascript dependencies
|
||||
@npm ci
|
||||
|
||||
.podinstall:
|
||||
ifeq ($(OS), Darwin)
|
||||
ifdef POD
|
||||
@echo Getting Cocoapods dependencies;
|
||||
@cd ios && pod install;
|
||||
else
|
||||
@echo "Cocoapods is not installed https://cocoapods.org/"
|
||||
@exit 1
|
||||
endif
|
||||
endif
|
||||
@touch $@
|
||||
|
||||
dist/assets: $(BASE_ASSETS) $(OVERRIDE_ASSETS)
|
||||
@mkdir -p dist
|
||||
|
||||
@if [ -e dist/assets ] ; then \
|
||||
rm -rf dist/assets; \
|
||||
fi
|
||||
|
||||
@echo "Generating app assets"
|
||||
@node scripts/make-dist-assets.js
|
||||
|
||||
pre-run: | node_modules .podinstall dist/assets ## Installs dependencies and assets
|
||||
|
||||
pre-build: | npm-ci .podinstall dist/assets ## Install dependencies and assets before building
|
||||
|
||||
check-style: node_modules ## Runs eslint
|
||||
@echo Checking for style guide compliance
|
||||
@npm run check
|
||||
|
||||
clean: ## Cleans dependencies, previous builds and temp files
|
||||
@echo Cleaning started
|
||||
|
||||
@rm -f .podinstall
|
||||
@rm -rf ios/Pods
|
||||
@rm -rf node_modules
|
||||
@rm -rf dist
|
||||
@rm -rf ios/build
|
||||
@rm -rf android/app/build
|
||||
|
||||
@echo Cleanup finished
|
||||
|
||||
post-install:
|
||||
@./node_modules/.bin/patch-package
|
||||
@./node_modules/.bin/jetify
|
||||
|
||||
@rm -f node_modules/intl/.babelrc
|
||||
@# Hack to get react-intl and its dependencies to work with react-native
|
||||
@# Based off of https://github.com/este/este/blob/master/gulp/native-fix.js
|
||||
@sed -i'' -e 's|"./locale-data/index.js": false|"./locale-data/index.js": "./locale-data/index.js"|g' node_modules/react-intl/package.json
|
||||
@sed -i'' -e 's|"./lib/locales": false|"./lib/locales": "./lib/locales"|g' node_modules/intl-messageformat/package.json
|
||||
@sed -i'' -e 's|"./lib/locales": false|"./lib/locales": "./lib/locales"|g' node_modules/intl-relativeformat/package.json
|
||||
@sed -i'' -e 's|"./locale-data/complete.js": false|"./locale-data/complete.js": "./locale-data/complete.js"|g' node_modules/intl/package.json
|
||||
|
||||
start: | pre-run ## Starts the React Native packager server
|
||||
$(call start_packager)
|
||||
|
||||
stop: ## Stops the React Native packager server
|
||||
$(call stop_packager)
|
||||
|
||||
check-device-ios:
|
||||
@if ! [ $(shell which xcodebuild) ]; then \
|
||||
echo "xcode is not installed"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! [ $(shell which watchman) ]; then \
|
||||
echo "watchman is not installed"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
check-device-android:
|
||||
@if ! [ $(ANDROID_HOME) ]; then \
|
||||
echo "ANDROID_HOME is not set"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! [ $(shell which adb 2> /dev/null) ]; then \
|
||||
echo "adb is not installed"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
@echo "Connect your Android device or open the emulator"
|
||||
@adb wait-for-device
|
||||
|
||||
@if ! [ $(shell which watchman 2> /dev/null) ]; then \
|
||||
echo "watchman is not installed"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
prepare-android-build:
|
||||
@rm -rf ./node_modules/react-native/local-cli/templates/HelloWorld
|
||||
@rm -rf ./node_modules/react-native-linear-gradient/Examples/
|
||||
@rm -rf ./node_modules/react-native-orientation/demo/
|
||||
|
||||
run: run-ios ## alias for run-ios
|
||||
|
||||
run-ios: | check-device-ios pre-run ## Runs the app on an iOS simulator
|
||||
@if [ $(shell ps -ef | grep -i "cli.js start" | grep -civ grep) -eq 0 ]; then \
|
||||
echo Starting React Native packager server; \
|
||||
npm start & echo Running iOS app in development; \
|
||||
if [ ! -z "${SIMULATOR}" ]; then \
|
||||
react-native run-ios --simulator="${SIMULATOR}"; \
|
||||
else \
|
||||
react-native run-ios; \
|
||||
fi; \
|
||||
wait; \
|
||||
else \
|
||||
echo Running iOS app in development; \
|
||||
if [ ! -z "${SIMULATOR}" ]; then \
|
||||
react-native run-ios --simulator="${SIMULATOR}"; \
|
||||
else \
|
||||
react-native run-ios; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
run-android: | check-device-android pre-run prepare-android-build ## Runs the app on an Android emulator or dev device
|
||||
@if [ $(shell ps -ef | grep -i "cli.js start" | grep -civ grep) -eq 0 ]; then \
|
||||
echo Starting React Native packager server; \
|
||||
npm start & echo Running Android app in development; \
|
||||
if [ ! -z ${VARIANT} ]; then \
|
||||
react-native run-android --no-packager --variant=${VARIANT}; \
|
||||
else \
|
||||
react-native run-android --no-packager; \
|
||||
fi; \
|
||||
wait; \
|
||||
else \
|
||||
echo Running Android app in development; \
|
||||
if [ ! -z ${VARIANT} ]; then \
|
||||
react-native run-android --no-packager --variant=${VARIANT}; \
|
||||
else \
|
||||
react-native run-android --no-packager; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
build: | stop pre-build check-style i18n-extract-ci ## Builds the app for Android & iOS
|
||||
$(call start_packager)
|
||||
@echo "Building App"
|
||||
@cd fastlane && BABEL_ENV=production NODE_ENV=production bundle exec fastlane build
|
||||
$(call stop_packager)
|
||||
|
||||
|
||||
build-ios: | stop pre-build check-style i18n-extract-ci ## Builds the iOS app
|
||||
$(call start_packager)
|
||||
@echo "Building iOS app"
|
||||
@cd fastlane && BABEL_ENV=production NODE_ENV=production bundle exec fastlane ios build
|
||||
$(call stop_packager)
|
||||
|
||||
build-android: | stop pre-build check-style i18n-extract-ci prepare-android-build ## Build the Android app
|
||||
$(call start_packager)
|
||||
@echo "Building Android app"
|
||||
@cd fastlane && BABEL_ENV=production NODE_ENV=production bundle exec fastlane android build
|
||||
$(call stop_packager)
|
||||
|
||||
unsigned-ios: stop pre-build check-style ## Build an unsigned version of the iOS app
|
||||
$(call start_packager)
|
||||
@cd fastlane && NODE_ENV=production bundle exec fastlane ios unsigned
|
||||
$(call stop_packager)
|
||||
|
||||
ios-sim-x86_64: stop pre-build check-style ## Build an unsigned x86_64 version of the iOS app for iPhone simulator
|
||||
$(call start_packager)
|
||||
@echo "Building unsigned x86_64 iOS app for iPhone simulator"
|
||||
@cd fastlane && NODE_ENV=production bundle exec fastlane ios unsigned
|
||||
@mkdir -p build-ios
|
||||
@cd ios/ && xcodebuild -workspace Mattermost.xcworkspace/ -scheme Mattermost -arch x86_64 -sdk iphonesimulator -configuration Release -parallelizeTargets -resultBundlePath ../build-ios/result -derivedDataPath ../build-ios/ ENABLE_BITCODE=NO CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO ENABLE_BITCODE=NO
|
||||
@cd build-ios/Build/Products/Release-iphonesimulator/ && zip -r Mattermost-simulator-x86_64.app.zip Mattermost.app/
|
||||
@mv build-ios/Build/Products/Release-iphonesimulator/Mattermost-simulator-x86_64.app.zip .
|
||||
@rm -rf build-ios/
|
||||
@cd fastlane && bundle exec fastlane upload_file_to_s3 file:Mattermost-simulator-x86_64.app.zip os_type:iOS
|
||||
$(call stop_packager)
|
||||
|
||||
unsigned-android: stop pre-build check-style prepare-android-build ## Build an unsigned version of the Android app
|
||||
@cd fastlane && NODE_ENV=production bundle exec fastlane android unsigned
|
||||
|
||||
test: | pre-run check-style ## Runs tests
|
||||
@npm test
|
||||
|
||||
build-pr: | can-build-pr stop pre-build check-style i18n-extract-ci ## Build a PR from the mattermost-mobile repo
|
||||
$(call start_packager)
|
||||
@echo "Building App from PR ${PR_ID}"
|
||||
@cd fastlane && BABEL_ENV=production NODE_ENV=production bundle exec fastlane build_pr pr:PR-${PR_ID}
|
||||
$(call stop_packager)
|
||||
|
||||
can-build-pr:
|
||||
@if [ -z ${PR_ID} ]; then \
|
||||
echo a PR number needs to be specified; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
i18n-extract: ## Extract strings for translation from the source code
|
||||
npm run mmjstool -- i18n extract-mobile
|
||||
|
||||
i18n-extract-ci:
|
||||
mkdir -p tmp
|
||||
cp assets/base/i18n/en.json tmp/en.json
|
||||
mkdir -p tmp/fake-webapp-dir/i18n/
|
||||
echo '{}' > tmp/fake-webapp-dir/i18n/en.json
|
||||
npm run mmjstool -- i18n extract-mobile --webapp-dir tmp/fake-webapp-dir --mobile-dir .
|
||||
diff tmp/en.json assets/base/i18n/en.json
|
||||
rm -rf tmp
|
||||
|
||||
## Help documentation https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
|
||||
help:
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
define start_packager
|
||||
@if [ $(shell ps -ef | grep -i "cli.js start" | grep -civ grep) -eq 0 ]; then \
|
||||
echo Starting React Native packager server; \
|
||||
npm start & echo; \
|
||||
else \
|
||||
echo React Native packager server already running; \
|
||||
fi
|
||||
endef
|
||||
|
||||
define stop_packager
|
||||
@echo Stopping React Native packager server
|
||||
@if [ $(shell ps -ef | grep -i "cli.js start" | grep -civ grep) -eq 1 ]; then \
|
||||
ps -ef | grep -i "cli.js start" | grep -iv grep | awk '{print $$2}' | xargs kill -9; \
|
||||
echo React Native packager server stopped; \
|
||||
else \
|
||||
echo No React Native packager server running; \
|
||||
fi
|
||||
endef
|
||||
1071
NOTICE.txt
1071
NOTICE.txt
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ Otherwise, link the JIRA ticket.
|
||||
Place an '[x]' (no spaces) in all applicable fields. Please remove unrelated fields.
|
||||
-->
|
||||
- [ ] Added or updated unit tests (required for all new features)
|
||||
- [ ] All new/modified APIs include changes to [mattermost-redux](https://github.com/mattermost/mattermost-redux) (please link)
|
||||
- [ ] Has UI changes
|
||||
- [ ] Includes text changes and localization file updates
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Mattermost Mobile
|
||||
|
||||
- **Minimum Server versions:** Current ESR version (5.19)
|
||||
- **Supported iOS versions:** 11+
|
||||
- **Supported Server versions:** 4.10+
|
||||
- **Supported iOS versions:** 10.3+
|
||||
- **Supported Android versions:** 7.0+
|
||||
|
||||
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 14 languages. Learn more at [https://about.mattermost.com](https://about.mattermost.com).
|
||||
@@ -20,7 +20,7 @@ To help with testing app updates before they're released, you can:
|
||||
|
||||
1. Sign up to be a beta tester
|
||||
- [Android](https://play.google.com/apps/testing/com.mattermost.rnbeta)
|
||||
- [iOS](https://testflight.apple.com/join/Q7Rx7K9P) - Open this link from your iOS device
|
||||
- [iOS](https://testflight.apple.com/join/Q7Rx7K9P)
|
||||
2. Install the `Mattermost Beta` app. New updates in the Beta app are released periodically. You will receive a notification when the new updates are available.
|
||||
3. File any bugs you find by filing a [GitHub issue](https://github.com/mattermost/mattermost-mobile/issues) with:
|
||||
- Device information
|
||||
|
||||
25
SECURITY.md
25
SECURITY.md
@@ -1,25 +0,0 @@
|
||||
Security
|
||||
========
|
||||
|
||||
Safety and data security is of the utmost priority for the Mattermost community. If you are a security researcher and have discovered a security vulnerability in our codebase, we would appreciate your help in disclosing it to us in a responsible manner.
|
||||
|
||||
Reporting security issues
|
||||
-------------------------
|
||||
|
||||
**Please do not use GitHub issues for security-sensitive communication.**
|
||||
|
||||
Security issues in the community test server, any of the open source codebases maintained by Mattermost, or any of our commercial offerings should be reported via email to [responsibledisclosure@mattermost.com](mailto:responsibledisclosure@mattermost.com). Mattermost is committed to working together with researchers and keeping them updated throughout the patching process. Researchers who responsibly report valid security issues will be publicly credited for their efforts (if they so choose).
|
||||
|
||||
For a more detailed description of the disclosure process and a list of researchers who have previously contributed to the disclosure program, see [Report a Security Vulnerability](https://mattermost.com/security-vulnerability-report/) on the Mattermost website.
|
||||
|
||||
Security updates
|
||||
----------------
|
||||
|
||||
Mattermost has a mandatory upgrade policy, and updates are only provided for the latest release. Critical updates are delivered as dot releases. Details on security updates are announced 30 days after the availability of the update.
|
||||
|
||||
For more details about the security content of past releases, see the [Security Updates](https://mattermost.com/security-updates/) page on the Mattermost website. For timely notifications about new security updates, subscribe to the [Security Bulletins Mailing List](https://about.mattermost.com/security-bulletin).
|
||||
|
||||
Contributing to this policy
|
||||
---------------------------
|
||||
|
||||
If you have feedback or suggestions on improving this policy document, please [create an issue](https://github.com/mattermost/mattermost-mobile/issues/new).
|
||||
@@ -15,9 +15,7 @@ import com.android.build.OutputFile
|
||||
* // the name of the generated asset file containing your JS bundle
|
||||
* bundleAssetName: "index.android.bundle",
|
||||
*
|
||||
* // the entry file for bundle generation. If none specified and
|
||||
* // "index.android.js" exists, it will be used. Otherwise "index.js" is
|
||||
* // default. Can be overridden with ENTRY_FILE environment variable.
|
||||
* // the entry file for bundle generation
|
||||
* entryFile: "index.android.js",
|
||||
*
|
||||
* // whether to bundle JS and assets in debug mode
|
||||
@@ -77,7 +75,6 @@ import com.android.build.OutputFile
|
||||
project.ext.react = [
|
||||
entryFile: "index.js",
|
||||
bundleConfig: "metro.config.js",
|
||||
bundleCommand: "ram-bundle",
|
||||
enableHermes: false,
|
||||
]
|
||||
|
||||
@@ -101,14 +98,25 @@ if (System.getenv("SENTRY_ENABLED") == "true") {
|
||||
* Upload all the APKs to the Play Store and people will download
|
||||
* the correct one based on the CPU architecture of their device.
|
||||
*/
|
||||
def enableSeparateBuildPerCPUArchitecture = project.hasProperty('separateApk') ? project.property('separateApk').toBoolean() : false
|
||||
def enableSeparateBuildPerCPUArchitecture = false
|
||||
|
||||
/**
|
||||
* Run Proguard to shrink the Java bytecode in release builds.
|
||||
*/
|
||||
def enableProguardInReleaseBuilds = false
|
||||
|
||||
def jscFlavor = 'org.webkit:android-jsc-intl:+'
|
||||
/**
|
||||
* The preferred build flavor of JavaScriptCore.
|
||||
*
|
||||
* For example, to use the international variant, you can use:
|
||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||
*
|
||||
* The international variant includes ICU i18n library and necessary data
|
||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* this variant is about 6MiB larger per architecture than default.
|
||||
*/
|
||||
def jscFlavor = 'org.webkit:android-jsc-intl:r241213'
|
||||
|
||||
/**
|
||||
* Whether to enable the Hermes VM.
|
||||
@@ -131,12 +139,14 @@ android {
|
||||
applicationId "com.mattermost.rnbeta"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
|
||||
versionCode 334
|
||||
versionName "1.37.0"
|
||||
missingDimensionStrategy "RNN.reactNativeVersion", "reactNative60"
|
||||
versionCode 258
|
||||
versionName "1.26.2"
|
||||
multiDexEnabled = true
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a','arm64-v8a','x86','x86_64'
|
||||
}
|
||||
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
@@ -152,7 +162,7 @@ android {
|
||||
abi {
|
||||
reset()
|
||||
enable enableSeparateBuildPerCPUArchitecture
|
||||
universalApk enableSeparateBuildPerCPUArchitecture // If true, also generate a universal APK
|
||||
universalApk false // If true, also generate a universal APK
|
||||
include "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
}
|
||||
@@ -181,7 +191,7 @@ android {
|
||||
def abi = output.getFilter(OutputFile.ABI)
|
||||
if (abi != null) { // null for the universal-debug, universal-release variants
|
||||
output.versionCodeOverride =
|
||||
versionCodes.get(abi) * 1000000 + defaultConfig.versionCode
|
||||
versionCodes.get(abi) * 1048576 + defaultConfig.versionCode
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +200,6 @@ android {
|
||||
sourceCompatibility 1.8
|
||||
targetCompatibility 1.8
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
repositories {
|
||||
@@ -219,47 +228,53 @@ configurations.all {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: "libs", include: ["*.jar"])
|
||||
|
||||
//noinspection GradleDynamicVersio
|
||||
implementation "com.facebook.react:react-native:+" // From node_modules
|
||||
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
|
||||
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.facebook.fbjni'
|
||||
}
|
||||
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.facebook.flipper'
|
||||
}
|
||||
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.facebook.flipper'
|
||||
}
|
||||
|
||||
|
||||
if (enableHermes) {
|
||||
def hermesPath = "../../node_modules/hermes-engine/android/";
|
||||
debugImplementation files(hermesPath + "hermes-debug.aar")
|
||||
releaseImplementation files(hermesPath + "hermes-release.aar")
|
||||
unsignedImplementation files(hermesPath + "hermes-release.aar")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
|
||||
implementation fileTree(dir: "libs", include: ["*.jar"])
|
||||
implementation "com.facebook.react:react-native:+" // From node_modules
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.0.0'
|
||||
implementation 'com.google.android.material:material:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
|
||||
implementation "com.google.firebase:firebase-messaging:17.3.0"
|
||||
implementation project(':react-native-document-picker')
|
||||
implementation project(':react-native-keychain')
|
||||
implementation project(':react-native-doc-viewer')
|
||||
implementation project(':react-native-video')
|
||||
implementation project(':react-native-navigation')
|
||||
implementation project(':react-native-image-picker')
|
||||
implementation project(':react-native-device-info')
|
||||
implementation project(':reactnativenotifications')
|
||||
implementation 'com.google.firebase:firebase-messaging:17.3.4'
|
||||
implementation project(':react-native-cookies')
|
||||
implementation project(':react-native-linear-gradient')
|
||||
implementation project(':react-native-vector-icons')
|
||||
implementation project(':react-native-svg')
|
||||
implementation project(':react-native-local-auth')
|
||||
implementation project(':jail-monkey')
|
||||
implementation project(':react-native-youtube')
|
||||
implementation project(':react-native-exception-handler')
|
||||
implementation project(':rn-fetch-blob')
|
||||
implementation project(':react-native-webview')
|
||||
implementation project(':react-native-gesture-handler')
|
||||
implementation project(':@react-native-community_async-storage')
|
||||
implementation project(':@react-native-community_netinfo')
|
||||
implementation project(':@sentry_react-native')
|
||||
implementation project(':react-native-android-open-settings')
|
||||
implementation project(':react-native-haptic-feedback')
|
||||
|
||||
implementation project(':react-native-fast-image')
|
||||
// For animated GIF support
|
||||
implementation 'com.facebook.fresco:fresco:2.0.0'
|
||||
implementation 'com.facebook.fresco:animated-gif:2.0.0'
|
||||
// For WebP support, including animated WebP
|
||||
implementation 'com.facebook.fresco:animated-webp:2.0.0'
|
||||
implementation 'com.facebook.fresco:webpsupport:2.0.0'
|
||||
|
||||
androidTestImplementation('com.wix:detox:+')
|
||||
}
|
||||
|
||||
// Run this once to be able to run the application with BUCK
|
||||
@@ -270,4 +285,3 @@ task copyDownloadableDepsToLibs(type: Copy) {
|
||||
}
|
||||
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import com.wix.detox.Detox;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.filters.LargeTest;
|
||||
import androidx.test.rule.ActivityTestRule;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
@LargeTest
|
||||
public class DetoxTest {
|
||||
|
||||
@Rule
|
||||
public ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule<>(MainActivity.class, false, false);
|
||||
|
||||
@Test
|
||||
public void runDetoxTests() {
|
||||
Detox.DetoxIdlePolicyConfig idlePolicyConfig = new Detox.DetoxIdlePolicyConfig();
|
||||
idlePolicyConfig.masterTimeoutSec = 60;
|
||||
idlePolicyConfig.idleResourceTimeoutSec = 30;
|
||||
|
||||
Detox.runTests(mActivityRule, idlePolicyConfig);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
|
||||
* directory of this source tree.
|
||||
*/
|
||||
package com.rn;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.flipper.android.AndroidFlipperClient;
|
||||
import com.facebook.flipper.android.utils.FlipperUtils;
|
||||
import com.facebook.flipper.core.FlipperClient;
|
||||
import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
|
||||
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
|
||||
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
|
||||
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
|
||||
import com.facebook.react.ReactInstanceManager;
|
||||
import com.facebook.react.bridge.ReactContext;
|
||||
import com.facebook.react.modules.network.NetworkingModule;
|
||||
import okhttp3.OkHttpClient;
|
||||
public class ReactNativeFlipper {
|
||||
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
|
||||
if (FlipperUtils.shouldEnableFlipper(context)) {
|
||||
final FlipperClient client = AndroidFlipperClient.getInstance(context);
|
||||
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
|
||||
client.addPlugin(new ReactFlipperPlugin());
|
||||
client.addPlugin(new DatabasesFlipperPlugin(context));
|
||||
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
|
||||
client.addPlugin(CrashReporterPlugin.getInstance());
|
||||
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
|
||||
NetworkingModule.setCustomClientBuilder(
|
||||
new NetworkingModule.CustomClientBuilder() {
|
||||
@Override
|
||||
public void apply(OkHttpClient.Builder builder) {
|
||||
builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
|
||||
}
|
||||
});
|
||||
client.addPlugin(networkFlipperPlugin);
|
||||
client.start();
|
||||
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
|
||||
// Hence we run if after all native modules have been initialized
|
||||
ReactContext reactContext = reactInstanceManager.getCurrentReactContext();
|
||||
if (reactContext == null) {
|
||||
reactInstanceManager.addReactInstanceEventListener(
|
||||
new ReactInstanceManager.ReactInstanceEventListener() {
|
||||
@Override
|
||||
public void onReactContextInitialized(ReactContext reactContext) {
|
||||
reactInstanceManager.removeReactInstanceEventListener(this);
|
||||
reactContext.runOnNativeModulesQueueThread(
|
||||
new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
client.addPlugin(new FrescoFlipperPlugin());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
client.addPlugin(new FrescoFlipperPlugin());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@
|
||||
android:theme="@style/AppTheme"
|
||||
android:installLocation="auto"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
>
|
||||
<meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
|
||||
<meta-data android:name="android.content.APP_RESTRICTIONS"
|
||||
@@ -29,20 +28,14 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/app_name"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTask">
|
||||
android:launchMode="singleInstance">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<action android:name="android.intent.action.DOWNLOAD_COMPLETE"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="mattermost" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
|
||||
<service android:name=".NotificationDismissService"
|
||||
@@ -61,8 +54,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/AppTheme"
|
||||
android:taskAffinity="com.mattermost.share"
|
||||
android:launchMode="singleInstance">
|
||||
android:taskAffinity="com.mattermost.share">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
|
||||
BIN
android/app/src/main/assets/fonts/AntDesign.ttf
Normal file
BIN
android/app/src/main/assets/fonts/AntDesign.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Entypo.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Entypo.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/EvilIcons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/EvilIcons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Feather.ttf
Executable file
BIN
android/app/src/main/assets/fonts/Feather.ttf
Executable file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/FontAwesome.ttf
Normal file
BIN
android/app/src/main/assets/fonts/FontAwesome.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/FontAwesome5_Brands.ttf
Normal file
BIN
android/app/src/main/assets/fonts/FontAwesome5_Brands.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/FontAwesome5_Regular.ttf
Normal file
BIN
android/app/src/main/assets/fonts/FontAwesome5_Regular.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/FontAwesome5_Solid.ttf
Normal file
BIN
android/app/src/main/assets/fonts/FontAwesome5_Solid.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Foundation.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Foundation.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Ionicons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Ionicons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/MaterialIcons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/MaterialIcons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Octicons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Octicons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/SimpleLineIcons.ttf
Normal file
BIN
android/app/src/main/assets/fonts/SimpleLineIcons.ttf
Normal file
Binary file not shown.
BIN
android/app/src/main/assets/fonts/Zocial.ttf
Normal file
BIN
android/app/src/main/assets/fonts/Zocial.ttf
Normal file
Binary file not shown.
Binary file not shown.
@@ -50,7 +50,6 @@ public class CustomPushNotification extends PushNotification {
|
||||
|
||||
private static final String PUSH_TYPE_MESSAGE = "message";
|
||||
private static final String PUSH_TYPE_CLEAR = "clear";
|
||||
private static final String PUSH_TYPE_SESSION = "session";
|
||||
private static final String PUSH_TYPE_UPDATE_BADGE = "update_badge";
|
||||
|
||||
private NotificationChannel mHighImportanceChannel;
|
||||
@@ -164,7 +163,6 @@ public class CustomPushNotification extends PushNotification {
|
||||
|
||||
switch(type) {
|
||||
case PUSH_TYPE_MESSAGE:
|
||||
case PUSH_TYPE_SESSION:
|
||||
super.postNotification(notificationId);
|
||||
break;
|
||||
case PUSH_TYPE_CLEAR:
|
||||
@@ -172,19 +170,15 @@ public class CustomPushNotification extends PushNotification {
|
||||
break;
|
||||
}
|
||||
|
||||
if (mAppLifecycleFacade.isReactInitialized()) {
|
||||
notifyReceivedToJS();
|
||||
}
|
||||
notifyReceivedToJS();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpened() {
|
||||
Bundle data = mNotificationProps.asBundle();
|
||||
final String channelId = data.getString("channel_id");
|
||||
if (channelId != null) {
|
||||
channelIdToNotificationCount.remove(channelId);
|
||||
channelIdToNotification.remove(channelId);
|
||||
}
|
||||
channelIdToNotificationCount.remove(channelId);
|
||||
channelIdToNotification.remove(channelId);
|
||||
digestNotification();
|
||||
}
|
||||
|
||||
@@ -228,9 +222,7 @@ public class CustomPushNotification extends PushNotification {
|
||||
}
|
||||
|
||||
String channelId = bundle.getString("channel_id");
|
||||
if (channelId != null) {
|
||||
userInfoBundle.putString("channel_id", channelId);
|
||||
}
|
||||
userInfoBundle.putString("channel_id", channelId);
|
||||
|
||||
notification.addExtras(userInfoBundle);
|
||||
}
|
||||
@@ -467,8 +459,7 @@ public class CustomPushNotification extends PushNotification {
|
||||
|
||||
private void addNotificationReplyAction(Notification.Builder notification, int notificationId, Bundle bundle) {
|
||||
String postId = bundle.getString("post_id");
|
||||
|
||||
if (android.text.TextUtils.isEmpty(postId) || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
if (postId == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -534,12 +525,7 @@ public class CustomPushNotification extends PushNotification {
|
||||
}
|
||||
|
||||
private String removeSenderNameFromMessage(String message, String senderName) {
|
||||
Integer index = message.indexOf(senderName);
|
||||
if (index == 0) {
|
||||
message = message.substring(senderName.length());
|
||||
}
|
||||
|
||||
return message.replaceFirst(": ", "").trim();
|
||||
return message.replaceFirst(senderName, "").replaceFirst(": ", "").trim();
|
||||
}
|
||||
|
||||
private void notificationReceiptDelivery(String ackId, String postId, String type, boolean isIdLoaded, ResolvePromise promise) {
|
||||
|
||||
@@ -2,49 +2,13 @@ package com.mattermost.rnbeta;
|
||||
|
||||
import android.os.Bundle;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.view.KeyEvent;
|
||||
import android.content.res.Configuration;
|
||||
|
||||
import com.reactnativenavigation.NavigationActivity;
|
||||
import com.github.emilioicai.hwkeyboardevent.HWKeyboardEventModule;
|
||||
|
||||
public class MainActivity extends NavigationActivity {
|
||||
private boolean HWKeyboardConnected = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.launch_screen);
|
||||
setHWKeyboardConnected();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
|
||||
if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO) {
|
||||
HWKeyboardConnected = true;
|
||||
} else if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_YES) {
|
||||
HWKeyboardConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
https://mattermost.atlassian.net/browse/MM-10601
|
||||
Required by react-native-hw-keyboard-event
|
||||
(https://github.com/emilioicai/react-native-hw-keyboard-event)
|
||||
*/
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
if (HWKeyboardConnected && event.getKeyCode() == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
|
||||
String keyPressed = event.isShiftPressed() ? "shift-enter" : "enter";
|
||||
HWKeyboardEventModule.getInstance().keyPressed(keyPressed);
|
||||
return true;
|
||||
}
|
||||
return super.dispatchKeyEvent(event);
|
||||
};
|
||||
|
||||
private void setHWKeyboardConnected() {
|
||||
HWKeyboardConnected = getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.content.Context;
|
||||
import android.content.RestrictionsManager;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.io.File;
|
||||
import java.util.HashMap;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.mattermost.share.RealPathUtil;
|
||||
import com.mattermost.share.ShareModule;
|
||||
import com.wix.reactnativenotifications.RNNotificationsPackage;
|
||||
import com.learnium.RNDeviceInfo.RNDeviceModule;
|
||||
import com.imagepicker.ImagePickerModule;
|
||||
import com.psykar.cookiemanager.CookieManagerModule;
|
||||
import com.oblador.vectoricons.VectorIconsModule;
|
||||
import com.wix.reactnativenotifications.RNNotificationsModule;
|
||||
import io.tradle.react.LocalAuthModule;
|
||||
import com.gantix.JailMonkey.JailMonkeyModule;
|
||||
import com.RNFetchBlob.RNFetchBlob;
|
||||
import com.masteratul.exceptionhandler.ReactNativeExceptionHandlerModule;
|
||||
import com.inprogress.reactnativeyoutube.YouTubeStandaloneModule;
|
||||
import com.philipphecht.RNDocViewerModule;
|
||||
import io.github.elyx0.reactnativedocumentpicker.DocumentPickerModule;
|
||||
import com.oblador.keychain.KeychainModule;
|
||||
import com.reactnativecommunity.asyncstorage.AsyncStorageModule;
|
||||
import com.reactnativecommunity.netinfo.NetInfoModule;
|
||||
import com.reactnativecommunity.webview.RNCWebViewPackage;
|
||||
import io.sentry.RNSentryModule;
|
||||
import com.dylanvann.fastimage.FastImageViewPackage;
|
||||
import com.levelasquez.androidopensettings.AndroidOpenSettings;
|
||||
import com.mkuczera.RNReactNativeHapticFeedbackModule;
|
||||
|
||||
import com.reactnativecommunity.webview.RNCWebViewPackage;
|
||||
import com.brentvatne.react.ReactVideoPackage;
|
||||
import com.BV.LinearGradient.LinearGradientPackage;
|
||||
import com.horcrux.svg.SvgPackage;
|
||||
import com.swmansion.gesturehandler.react.RNGestureHandlerPackage;
|
||||
|
||||
import com.reactnativenavigation.NavigationApplication;
|
||||
import com.reactnativenavigation.react.NavigationReactNativeHost;
|
||||
import com.reactnativenavigation.react.ReactGateway;
|
||||
import com.wix.reactnativenotifications.core.notification.INotificationsApplication;
|
||||
import com.wix.reactnativenotifications.core.notification.IPushNotification;
|
||||
import com.wix.reactnativenotifications.core.notificationdrawer.IPushNotificationsDrawer;
|
||||
@@ -24,8 +51,6 @@ import com.wix.reactnativenotifications.core.AppLaunchHelper;
|
||||
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
|
||||
import com.wix.reactnativenotifications.core.JsIOHelper;
|
||||
|
||||
import com.facebook.react.PackageList;
|
||||
import com.facebook.react.ReactInstanceManager;
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.ReactNativeHost;
|
||||
import com.facebook.react.TurboReactPackage;
|
||||
@@ -41,6 +66,8 @@ import com.facebook.react.module.model.ReactModuleInfoProvider;
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule;
|
||||
import com.facebook.soloader.SoLoader;
|
||||
|
||||
import com.mattermost.share.RealPathUtil;
|
||||
|
||||
public class MainApplication extends NavigationApplication implements INotificationsApplication, INotificationsDrawerApplication {
|
||||
public static MainApplication instance;
|
||||
|
||||
@@ -56,34 +83,76 @@ public class MainApplication extends NavigationApplication implements INotificat
|
||||
|
||||
private Bundle mManagedConfig = null;
|
||||
|
||||
private final ReactNativeHost mReactNativeHost =
|
||||
new ReactNativeHost(this) {
|
||||
@Override
|
||||
public boolean getUseDeveloperSupport() {
|
||||
return BuildConfig.DEBUG;
|
||||
}
|
||||
@Override
|
||||
protected ReactGateway createReactGateway() {
|
||||
ReactNativeHost host = new NavigationReactNativeHost(this, isDebug(), createAdditionalReactPackages()) {
|
||||
@Override
|
||||
protected String getJSMainModuleName() {
|
||||
return "index";
|
||||
}
|
||||
};
|
||||
return new ReactGateway(this, isDebug(), host);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<ReactPackage> getPackages() {
|
||||
@SuppressWarnings("UnnecessaryLocalVariable")
|
||||
List<ReactPackage> packages = new PackageList(this).getPackages();
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// packages.add(new MyReactNativePackage());
|
||||
packages.add(new RNNotificationsPackage(MainApplication.this));
|
||||
packages.add(new RNPasteableTextInputPackage());
|
||||
packages.add(
|
||||
new TurboReactPackage() {
|
||||
@Override
|
||||
public boolean isDebug() {
|
||||
return BuildConfig.DEBUG;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<ReactPackage> createAdditionalReactPackages() {
|
||||
// Add the packages you require here.
|
||||
// No need to add RnnPackage and MainReactPackage
|
||||
return Arrays.<ReactPackage>asList(
|
||||
new TurboReactPackage() {
|
||||
@Override
|
||||
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
|
||||
switch (name) {
|
||||
case "MattermostManaged":
|
||||
return MattermostManagedModule.getInstance(reactContext);
|
||||
case "MattermostShare":
|
||||
return new ShareModule(instance, reactContext);
|
||||
case "RNDeviceInfo":
|
||||
return new RNDeviceModule(reactContext, false);
|
||||
case "ImagePickerManager":
|
||||
return new ImagePickerModule(reactContext, R.style.DefaultExplainingPermissionsTheme);
|
||||
case "RNCookieManagerAndroid":
|
||||
return new CookieManagerModule(reactContext);
|
||||
case "RNVectorIconsModule":
|
||||
return new VectorIconsModule(reactContext);
|
||||
case "WixRNNotifications":
|
||||
return new RNNotificationsModule(instance, reactContext);
|
||||
case "RNLocalAuth":
|
||||
return new LocalAuthModule(reactContext);
|
||||
case "JailMonkey":
|
||||
return new JailMonkeyModule(reactContext, false);
|
||||
case "RNFetchBlob":
|
||||
return new RNFetchBlob(reactContext);
|
||||
case "MattermostManaged":
|
||||
return MattermostManagedModule.getInstance(reactContext);
|
||||
case "NotificationPreferences":
|
||||
return NotificationPreferencesModule.getInstance(instance, reactContext);
|
||||
case "RNTextInputReset":
|
||||
return new RNTextInputResetModule(reactContext);
|
||||
case "ReactNativeExceptionHandler":
|
||||
return new ReactNativeExceptionHandlerModule(reactContext);
|
||||
case "YouTubeStandaloneModule":
|
||||
return new YouTubeStandaloneModule(reactContext);
|
||||
case "RNDocViewer":
|
||||
return new RNDocViewerModule(reactContext);
|
||||
case "RNDocumentPicker":
|
||||
return new DocumentPickerModule(reactContext);
|
||||
case "RNKeychainManager":
|
||||
return new KeychainModule(reactContext);
|
||||
case "RNSentry":
|
||||
return new RNSentryModule(reactContext);
|
||||
case AsyncStorageModule.NAME:
|
||||
return new AsyncStorageModule(reactContext);
|
||||
case NetInfoModule.NAME:
|
||||
return new NetInfoModule(reactContext);
|
||||
case "RNAndroidOpenSettings":
|
||||
return new AndroidOpenSettings(reactContext);
|
||||
case "RNReactNativeHapticFeedbackModule":
|
||||
return new RNReactNativeHapticFeedbackModule(reactContext);
|
||||
default:
|
||||
throw new IllegalArgumentException("Could not find module " + name);
|
||||
}
|
||||
@@ -96,28 +165,41 @@ private final ReactNativeHost mReactNativeHost =
|
||||
public Map<String, ReactModuleInfo> getReactModuleInfos() {
|
||||
Map<String, ReactModuleInfo> map = new HashMap<>();
|
||||
map.put("MattermostManaged", new ReactModuleInfo("MattermostManaged", "com.mattermost.rnbeta.MattermostManagedModule", false, false, false, false, false));
|
||||
map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false));
|
||||
map.put("NotificationPreferences", new ReactModuleInfo("NotificationPreferences", "com.mattermost.rnbeta.NotificationPreferencesModule", false, false, false, false, false));
|
||||
map.put("RNTextInputReset", new ReactModuleInfo("RNTextInputReset", "com.mattermost.rnbeta.RNTextInputResetModule", false, false, false, false, false));
|
||||
|
||||
map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false));
|
||||
map.put("RNDeviceInfo", new ReactModuleInfo("RNDeviceInfo", "com.learnium.RNDeviceInfo.RNDeviceModule", false, false, true, false, false));
|
||||
map.put("ImagePickerManager", new ReactModuleInfo("ImagePickerManager", "com.imagepicker.ImagePickerModule", false, false, false, false, false));
|
||||
map.put("RNCookieManagerAndroid", new ReactModuleInfo("RNCookieManagerAndroid", "com.psykar.cookiemanager.CookieManagerModule", false, false, false, false, false));
|
||||
map.put("RNVectorIconsModule", new ReactModuleInfo("RNVectorIconsModule", "com.oblador.vectoricons.VectorIconsModule", false, false, false, false, false));
|
||||
map.put("WixRNNotifications", new ReactModuleInfo("WixRNNotifications", "com.wix.reactnativenotifications.RNNotificationsModule", false, false, false, false, false));
|
||||
map.put("RNLocalAuth", new ReactModuleInfo("RNLocalAuth", "io.tradle.react.LocalAuthModule", false, false, false, false, false));
|
||||
map.put("JailMonkey", new ReactModuleInfo("JailMonkey", "com.gantix.JailMonkey.JailMonkeyModule", false, false, true, false, false));
|
||||
map.put("RNFetchBlob", new ReactModuleInfo("RNFetchBlob", "com.RNFetchBlob.RNFetchBlob", false, false, true, false, false));
|
||||
map.put("ReactNativeExceptionHandler", new ReactModuleInfo("ReactNativeExceptionHandler", "com.masteratul.exceptionhandler.ReactNativeExceptionHandlerModule", false, false, false, false, false));
|
||||
map.put("YouTubeStandaloneModule", new ReactModuleInfo("YouTubeStandaloneModule", "com.inprogress.reactnativeyoutube.YouTubeStandaloneModule", false, false, false, false, false));
|
||||
map.put("RNDocViewer", new ReactModuleInfo("RNDocViewer", "com.philipphecht.RNDocViewerModule", false, false, false, false, false));
|
||||
map.put("RNDocumentPicker", new ReactModuleInfo("RNDocumentPicker", "io.github.elyx0.reactnativedocumentpicker.DocumentPickerModule", false, false, false, false, false));
|
||||
map.put("RNKeychainManager", new ReactModuleInfo("RNKeychainManager", "com.oblador.keychain.KeychainModule", false, false, true, false, false));
|
||||
map.put("RNSentry", new ReactModuleInfo("RNSentry", "com.sentry.RNSentryModule", false, false, true, false, false));
|
||||
map.put(AsyncStorageModule.NAME, new ReactModuleInfo(AsyncStorageModule.NAME, "com.reactnativecommunity.asyncstorage.AsyncStorageModule", false, false, false, false, false));
|
||||
map.put(NetInfoModule.NAME, new ReactModuleInfo(NetInfoModule.NAME, "com.reactnativecommunity.netinfo.NetInfoModule", false, false, false, false, false));
|
||||
map.put("RNAndroidOpenSettings", new ReactModuleInfo("RNAndroidOpenSettings", "com.levelasquez.androidopensettings.AndroidOpenSettings", false, false, false, false, false));
|
||||
map.put("RNReactNativeHapticFeedbackModule", new ReactModuleInfo("RNReactNativeHapticFeedback", "com.mkuczera.RNReactNativeHapticFeedbackModule", false, false, false, false, false));
|
||||
return map;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getJSMainModuleName() {
|
||||
return "index";
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public ReactNativeHost getReactNativeHost() {
|
||||
return mReactNativeHost;
|
||||
},
|
||||
new FastImageViewPackage(),
|
||||
new RNCWebViewPackage(),
|
||||
new SvgPackage(),
|
||||
new LinearGradientPackage(),
|
||||
new ReactVideoPackage(),
|
||||
new RNGestureHandlerPackage(),
|
||||
new RNPasteableTextInputPackage()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -126,12 +208,11 @@ private final ReactNativeHost mReactNativeHost =
|
||||
instance = this;
|
||||
|
||||
// Delete any previous temp files created by the app
|
||||
File tempFolder = new File(getApplicationContext().getCacheDir(), ShareModule.CACHE_DIR_NAME);
|
||||
File tempFolder = new File(getApplicationContext().getCacheDir(), "mmShare");
|
||||
RealPathUtil.deleteTempFiles(tempFolder);
|
||||
Log.i("ReactNative", "Cleaning temp cache " + tempFolder.getAbsolutePath());
|
||||
|
||||
SoLoader.init(this, /* native exopackage */ false);
|
||||
initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
|
||||
|
||||
// Uncomment to listen to react markers for build that has telemetry enabled
|
||||
// addReactMarkerListener();
|
||||
@@ -154,11 +235,14 @@ private final ReactNativeHost mReactNativeHost =
|
||||
}
|
||||
|
||||
public ReactContext getRunningReactContext() {
|
||||
if (mReactNativeHost == null) {
|
||||
final ReactGateway reactGateway = getReactGateway();
|
||||
|
||||
if (reactGateway == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mReactNativeHost
|
||||
return reactGateway
|
||||
.getReactNativeHost()
|
||||
.getReactInstanceManager()
|
||||
.getCurrentReactContext();
|
||||
}
|
||||
@@ -226,35 +310,4 @@ private final ReactNativeHost mReactNativeHost =
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Flipper in React Native templates. Call this in the onCreate method with something like
|
||||
* initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
|
||||
*
|
||||
* @param context
|
||||
* @param reactInstanceManager
|
||||
*/
|
||||
private static void initializeFlipper(
|
||||
Context context, ReactInstanceManager reactInstanceManager) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
try {
|
||||
/*
|
||||
We use reflection here to pick up the class that initializes Flipper,
|
||||
since Flipper library is not available in release mode
|
||||
*/
|
||||
Class<?> aClass = Class.forName("com.rndiffapp.ReactNativeFlipper");
|
||||
aClass
|
||||
.getMethod("initializeFlipper", Context.class, ReactInstanceManager.class)
|
||||
.invoke(null, context, reactInstanceManager);
|
||||
} catch (ClassNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
} catch (NoSuchMethodException e) {
|
||||
e.printStackTrace();
|
||||
} catch (IllegalAccessException e) {
|
||||
e.printStackTrace();
|
||||
} catch (InvocationTargetException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@ import android.content.Context;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
|
||||
import com.oblador.keychain.KeychainModule;
|
||||
|
||||
import com.mattermost.react_native_interface.ResolvePromise;
|
||||
|
||||
@@ -63,6 +63,7 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
|
||||
String token = map.getString("password");
|
||||
String serverUrl = map.getString("service");
|
||||
|
||||
Log.i("ReactNative", String.format("URL=%s", serverUrl));
|
||||
replyToMessage(serverUrl, token, notificationId, message);
|
||||
}
|
||||
}
|
||||
@@ -87,16 +88,12 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
|
||||
final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
|
||||
String json = buildReplyPost(channelId, rootId, message.toString());
|
||||
RequestBody body = RequestBody.create(JSON, json);
|
||||
|
||||
String postsEndpoint = "/api/v4/posts?set_online=false";
|
||||
String url = String.format("%s%s", serverUrl.replaceAll("/$", ""), postsEndpoint);
|
||||
Log.i("ReactNative", String.format("Reply URL=%s", url));
|
||||
Request request = new Request.Builder()
|
||||
.header("Authorization", String.format("Bearer %s", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.url(url)
|
||||
.post(body)
|
||||
.build();
|
||||
.header("Authorization", String.format("Bearer %s", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.url(String.format("%s/api/v4/posts", serverUrl.replaceAll("/$", "")))
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
client.newCall(request).enqueue(new okhttp3.Callback() {
|
||||
@Override
|
||||
|
||||
@@ -82,12 +82,7 @@ public class RNPasteableActionCallback implements ActionMode.Callback {
|
||||
return null;
|
||||
}
|
||||
|
||||
CharSequence chars = item.getText();
|
||||
if (chars == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String text = chars.toString();
|
||||
String text = item.getText().toString();
|
||||
if (text.length() > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -16,13 +16,8 @@ import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.uimanager.events.RCTEventEmitter;
|
||||
import com.mattermost.share.RealPathUtil;
|
||||
import com.mattermost.share.ShareModule;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.File;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteListener {
|
||||
@@ -88,14 +83,6 @@ public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteList
|
||||
// Get fileName
|
||||
String fileName = URLUtil.guessFileName(uri, null, mimeType);
|
||||
|
||||
if (uri.contains(ShareModule.CACHE_DIR_NAME)) {
|
||||
uri = moveToImagesCache(uri, fileName);
|
||||
}
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get fileSize
|
||||
long fileSize;
|
||||
try {
|
||||
@@ -132,25 +119,4 @@ public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteList
|
||||
event
|
||||
);
|
||||
}
|
||||
|
||||
private String moveToImagesCache(String src, String fileName) {
|
||||
ReactContext ctx = (ReactContext)mEditText.getContext();
|
||||
String cacheFolder = ctx.getCacheDir().getAbsolutePath() + "/Images/";
|
||||
String dest = cacheFolder + fileName;
|
||||
File folder = new File(cacheFolder);
|
||||
|
||||
try {
|
||||
if (!folder.exists()) {
|
||||
folder.mkdirs();
|
||||
}
|
||||
|
||||
Files.move(Paths.get(src), Paths.get(dest));
|
||||
} catch (FileAlreadyExistsException fileError) {
|
||||
// Do nothing and return dest path
|
||||
} catch (Exception err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dest;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,12 +71,8 @@ public class RNPasteableTextInputManager extends ReactTextInputManager {
|
||||
@Nullable
|
||||
@Override
|
||||
public Map<String, Object> getExportedCustomBubblingEventTypeConstants() {
|
||||
Map<String, Object> map = super.getExportedCustomBubblingEventTypeConstants();
|
||||
map.put(
|
||||
"onPaste",
|
||||
MapBuilder.of(
|
||||
"phasedRegistrationNames",
|
||||
MapBuilder.of("bubbled", "onPaste")));
|
||||
Map map = super.getExportedViewConstants();
|
||||
map.put("onPaste", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onPaste")));
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ import com.mattermost.react_native_interface.ResolvePromise;
|
||||
public class ReceiptDelivery {
|
||||
static final String CURRENT_SERVER_URL = "@currentServerUrl";
|
||||
|
||||
private static final int[] FIBONACCI_BACKOFFS = new int[] { 0, 1, 2, 3, 5, 8 };
|
||||
|
||||
public static void send(Context context, final String ackId, final String postId, final String type, final boolean isIdLoaded, ResolvePromise promise) {
|
||||
final ReactApplicationContext reactApplicationContext = new ReactApplicationContext(context);
|
||||
|
||||
@@ -97,60 +95,26 @@ public class ReceiptDelivery {
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
makeServerRequest(client, request, isIdLoaded, 0, promise);
|
||||
}
|
||||
}
|
||||
|
||||
private static void makeServerRequest(OkHttpClient client, Request request, Boolean isIdLoaded, int reRequestCount, ResolvePromise promise) {
|
||||
try {
|
||||
Response response = client.newCall(request).execute();
|
||||
String responseBody = response.body().string();
|
||||
if (response.code() != 200) {
|
||||
switch (response.code()) {
|
||||
case 302:
|
||||
promise.reject("Receipt delivery failure", "StatusFound");
|
||||
return;
|
||||
case 400:
|
||||
promise.reject("Receipt delivery failure", "StatusBadRequest");
|
||||
return;
|
||||
case 401:
|
||||
promise.reject("Receipt delivery failure", "Unauthorized");
|
||||
return;
|
||||
case 500:
|
||||
promise.reject("Receipt delivery failure", "StatusInternalServerError");
|
||||
return;
|
||||
case 501:
|
||||
promise.reject("Receipt delivery failure", "StatusNotImplemented");
|
||||
return;
|
||||
try {
|
||||
Response response = client.newCall(request).execute();
|
||||
String responseBody = response.body().string();
|
||||
if (response.code() != 200 || !isIdLoaded) {
|
||||
throw new Exception(responseBody);
|
||||
}
|
||||
|
||||
throw new Exception(responseBody);
|
||||
}
|
||||
|
||||
JSONObject jsonResponse = new JSONObject(responseBody);
|
||||
Bundle bundle = new Bundle();
|
||||
String keys[] = new String[]{"post_id", "category", "message", "team_id", "channel_id", "channel_name", "type", "sender_id", "sender_name", "version"};
|
||||
for (int i = 0; i < keys.length; i++) {
|
||||
String key = keys[i];
|
||||
if (jsonResponse.has(key)) {
|
||||
bundle.putString(key, jsonResponse.getString(key));
|
||||
}
|
||||
}
|
||||
promise.resolve(bundle);
|
||||
} catch (Exception e) {
|
||||
Log.e("ReactNative", "Receipt delivery failed to send");
|
||||
if (isIdLoaded) {
|
||||
try {
|
||||
reRequestCount++;
|
||||
if (reRequestCount < FIBONACCI_BACKOFFS.length) {
|
||||
Log.i("ReactNative", "Retry attempt " + reRequestCount + " with backoff delay: " + FIBONACCI_BACKOFFS[reRequestCount] + " seconds");
|
||||
Thread.sleep(FIBONACCI_BACKOFFS[reRequestCount] * 1000);
|
||||
makeServerRequest(client, request, isIdLoaded, reRequestCount, promise);
|
||||
JSONObject jsonResponse = new JSONObject(responseBody);
|
||||
Bundle bundle = new Bundle();
|
||||
String keys[] = new String[] {"post_id", "category", "message", "team_id", "channel_id", "channel_name", "type", "sender_id", "sender_name", "version"};
|
||||
for (int i = 0; i < keys.length; i++) {
|
||||
String key = keys[i];
|
||||
if (jsonResponse.has(key)) {
|
||||
bundle.putString(key, jsonResponse.getString(key));
|
||||
}
|
||||
} catch(InterruptedException ie) {}
|
||||
}
|
||||
promise.resolve(bundle);
|
||||
} catch (Exception e) {
|
||||
Log.e("ReactNative", "Receipt delivery failed to send");
|
||||
promise.reject("Receipt delivery failure", e.toString());
|
||||
}
|
||||
|
||||
promise.reject("Receipt delivery failure", e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ public class RealPathUtil {
|
||||
}
|
||||
|
||||
|
||||
File cacheDir = new File(context.getCacheDir(), ShareModule.CACHE_DIR_NAME);
|
||||
File cacheDir = new File(context.getCacheDir(), "mmShare");
|
||||
if (!cacheDir.exists()) {
|
||||
cacheDir.mkdirs();
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
private final OkHttpClient client = new OkHttpClient();
|
||||
public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
|
||||
private final MainApplication mApplication;
|
||||
public static final String CACHE_DIR_NAME = "mmShare";
|
||||
|
||||
public ShareModule(MainApplication application, ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
@@ -68,7 +67,6 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
@Override
|
||||
public Map<String, Object> getConstants() {
|
||||
HashMap<String, Object> constants = new HashMap<>(1);
|
||||
constants.put("cacheDirName", CACHE_DIR_NAME);
|
||||
constants.put("isOpened", mApplication.sharedExtensionIsOpened);
|
||||
mApplication.sharedExtensionIsOpened = false;
|
||||
return constants;
|
||||
@@ -135,7 +133,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
Activity currentActivity = getCurrentActivity();
|
||||
|
||||
if (currentActivity != null) {
|
||||
this.tempFolder = new File(currentActivity.getCacheDir(), CACHE_DIR_NAME);
|
||||
this.tempFolder = new File(currentActivity.getCacheDir(), "mmShare");
|
||||
Intent intent = currentActivity.getIntent();
|
||||
action = intent.getAction();
|
||||
type = intent.getType();
|
||||
@@ -194,12 +192,8 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
JSONObject json = new JSONObject();
|
||||
try {
|
||||
json.put("user_id", data.getString("currentUserId"));
|
||||
if (data.hasKey("channelId")) {
|
||||
json.put("channel_id", data.getString("channelId"));
|
||||
}
|
||||
if (data.hasKey("value")) {
|
||||
json.put("message", data.getString("value"));
|
||||
}
|
||||
json.put("channel_id", data.getString("channelId"));
|
||||
json.put("message", data.getString("value"));
|
||||
} catch (JSONException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
<string name="allowOtherServers_description">Allow the user to change the above server URL.</string>
|
||||
<string name="username_title">Default Username</string>
|
||||
<string name="username_description">Set the username or email address to use to authenticate against the Mattermost Server.</string>
|
||||
<string name="timeout_title">Default Request Timeout</string>
|
||||
<string name="timeout_description">How long in milliseconds the mobile app should wait for the server to respond.</string>
|
||||
<string name="vendor_title">EMM Vendor or Company Name</string>
|
||||
<string name="vendor_description">Name of the EMM vendor or company deploying the app. Used in help text when prompting for passcodes so users are aware why the app is being protected.</string>
|
||||
</resources>
|
||||
|
||||
@@ -43,12 +43,6 @@
|
||||
android:description="@string/username_description"
|
||||
android:restrictionType="string"
|
||||
android:defaultValue="" />
|
||||
<restriction
|
||||
android:key="timeout"
|
||||
android:title="@string/timeout_title"
|
||||
android:description="@string/timeout_description"
|
||||
android:restrictionType="string"
|
||||
android:defaultValue="10000" />
|
||||
<restriction
|
||||
android:key="vendor"
|
||||
android:title="@string/vendor_title"
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
buildscript {
|
||||
ext {
|
||||
buildToolsVersion = "29.0.2"
|
||||
buildToolsVersion = "28.0.3"
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 29
|
||||
targetSdkVersion = 29
|
||||
compileSdkVersion = 28
|
||||
targetSdkVersion = 28
|
||||
supportLibVersion = "28.0.0"
|
||||
kotlinVersion = "1.3.61"
|
||||
RNNKotlinVersion = kotlinVersion
|
||||
|
||||
}
|
||||
repositories {
|
||||
jcenter()
|
||||
@@ -18,9 +15,8 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.5.3'
|
||||
classpath 'com.android.tools.build:gradle:3.4.2'
|
||||
classpath 'com.google.gms:google-services:4.2.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
@@ -48,26 +44,14 @@ allprojects {
|
||||
jcenter()
|
||||
maven {
|
||||
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
||||
url("$rootDir/../node_modules/react-native/android")
|
||||
|
||||
// Replace AAR from original RN with AAR from react-native-v8
|
||||
// url("$rootDir/../node_modules/react-native-v8/dist")
|
||||
url "$rootDir/../node_modules/react-native/android"
|
||||
}
|
||||
maven {
|
||||
// Local Maven repo containing AARs with JSC library built for Android
|
||||
url("$rootDir/../node_modules/jsc-android/dist")
|
||||
|
||||
// prebuilt libv8android.so
|
||||
// url("$rootDir/../node_modules/v8-android/dist")
|
||||
url "$rootDir/../node_modules/jsc-android/dist"
|
||||
}
|
||||
maven {
|
||||
url "https://www.jitpack.io"
|
||||
}
|
||||
maven {
|
||||
url ("https://dl.bintray.com/rudderstack/rudderstack")
|
||||
}
|
||||
maven {
|
||||
url "$rootDir/../node_modules/detox/Detox-android"
|
||||
url "https://jitpack.io"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,5 @@ org.gradle.jvmargs=-Xmx2048M
|
||||
#android.enableAapt2=false
|
||||
#android.useDeprecatedNdk=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
|
||||
# Automatically convert third-party libraries to use AndroidX
|
||||
android.enableJetifier=true
|
||||
|
||||
# Version of flipper SDK to use with React Native
|
||||
FLIPPER_VERSION=0.37.0
|
||||
android.enableJetifier=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-5.5-all.zip
|
||||
|
||||
53
android/gradlew
vendored
53
android/gradlew
vendored
@@ -1,21 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
@@ -44,7 +28,7 @@ APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
@@ -125,8 +109,8 @@ if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
@@ -154,19 +138,19 @@ if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
@@ -175,9 +159,14 @@ save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
APP_ARGS=$(save "$@")
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||
cd "$(dirname "$0")"
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
||||
187
android/gradlew.bat
vendored
187
android/gradlew.bat
vendored
@@ -1,103 +1,84 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
rootProject.name = 'Mattermost'
|
||||
include ':@sentry_react-native'
|
||||
project(':@sentry_react-native').projectDir = new File(rootProject.projectDir, '../node_modules/@sentry/react-native/android')
|
||||
include ':react-native-android-open-settings'
|
||||
project(':react-native-android-open-settings').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-android-open-settings/android')
|
||||
include ':react-native-fast-image'
|
||||
project(':react-native-fast-image').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fast-image/android')
|
||||
include ':react-native-haptic-feedback'
|
||||
project(':react-native-haptic-feedback').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-haptic-feedback/android')
|
||||
include ':react-native-gesture-handler'
|
||||
project(':react-native-gesture-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-gesture-handler/android')
|
||||
include ':react-native-document-picker'
|
||||
project(':react-native-document-picker').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-document-picker/android')
|
||||
include ':react-native-keychain'
|
||||
project(':react-native-keychain').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-keychain/android')
|
||||
include ':react-native-doc-viewer'
|
||||
project(':react-native-doc-viewer').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-doc-viewer/android')
|
||||
include ':react-native-video'
|
||||
project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android')
|
||||
include ':react-native-youtube'
|
||||
project(':react-native-youtube').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-youtube/android')
|
||||
include ':react-native-exception-handler'
|
||||
project(':react-native-exception-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-exception-handler/android')
|
||||
include ':rn-fetch-blob'
|
||||
project(':rn-fetch-blob').projectDir = new File(rootProject.projectDir, '../node_modules/rn-fetch-blob/android')
|
||||
include ':jail-monkey'
|
||||
project(':jail-monkey').projectDir = new File(rootProject.projectDir, '../node_modules/jail-monkey/android')
|
||||
include ':react-native-local-auth'
|
||||
project(':react-native-local-auth').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-local-auth/android')
|
||||
include ':react-native-navigation'
|
||||
project(':react-native-navigation').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-navigation/lib/android/app/')
|
||||
include ':react-native-image-picker'
|
||||
project(':react-native-image-picker').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-image-picker/android')
|
||||
include ':react-native-device-info'
|
||||
project(':react-native-device-info').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-device-info/android')
|
||||
include ':react-native-cookies'
|
||||
project(':react-native-cookies').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-cookies/android')
|
||||
include ':react-native-vector-icons'
|
||||
project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android')
|
||||
include ':reactnativenotifications'
|
||||
project(':reactnativenotifications').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-notifications/android/app')
|
||||
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
|
||||
|
||||
include ':app'
|
||||
include ':react-native-svg'
|
||||
project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android')
|
||||
include ':react-native-linear-gradient'
|
||||
project(':react-native-linear-gradient').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-linear-gradient/android')
|
||||
include ':react-native-webview'
|
||||
project(':react-native-webview').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-webview/android')
|
||||
include ':@react-native-community_async-storage'
|
||||
project(':@react-native-community_async-storage').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/async-storage/android')
|
||||
include ':@react-native-community_netinfo'
|
||||
project(':@react-native-community_netinfo').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/netinfo/android')
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {networkStatusChangedAction} from 'redux-offline';
|
||||
|
||||
import {DeviceTypes} from 'app/constants';
|
||||
|
||||
export function connection(isOnline) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
if (isOnline !== undefined && isOnline !== state.device.connection) {
|
||||
dispatch({
|
||||
type: DeviceTypes.CONNECTION_CHANGED,
|
||||
data: isOnline,
|
||||
});
|
||||
}
|
||||
return async (dispatch) => {
|
||||
dispatch(networkStatusChangedAction(isOnline));
|
||||
dispatch({
|
||||
type: DeviceTypes.CONNECTION_CHANGED,
|
||||
data: isOnline,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable no-import-assign */
|
||||
|
||||
import {Client4} from '@mm-redux/client';
|
||||
|
||||
import {Preferences} from '@mm-redux/constants';
|
||||
import {PreferenceTypes} from '@mm-redux/action_types';
|
||||
|
||||
import * as CommonSelectors from '@mm-redux/selectors/entities/common';
|
||||
import * as PreferenceSelectors from '@mm-redux/selectors/entities/preferences';
|
||||
import * as PreferenceUtils from '@mm-redux/utils/preference_utils';
|
||||
|
||||
import {
|
||||
makeDirectChannelVisibleIfNecessary,
|
||||
makeGroupMessageVisibleIfNecessary,
|
||||
} from './channels';
|
||||
|
||||
describe('Actions.Helpers.Channels', () => {
|
||||
describe('makeDirectChannelVisibleIfNecessary', () => {
|
||||
const state = {};
|
||||
const currentUserId = 'current-user-id';
|
||||
const otherUserId = 'other-user-id';
|
||||
|
||||
CommonSelectors.getCurrentUserId = jest.fn().mockReturnValue(currentUserId);
|
||||
PreferenceSelectors.getMyPreferences = jest.fn();
|
||||
PreferenceUtils.getPreferenceKey = jest.fn();
|
||||
Client4.savePreferences = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
PreferenceSelectors.getMyPreferences.mockClear();
|
||||
PreferenceUtils.getPreferenceKey.mockClear();
|
||||
Client4.savePreferences.mockClear();
|
||||
});
|
||||
|
||||
it('makes direct channel visible when visibility preference does not exist', () => {
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({});
|
||||
|
||||
const expectedResult = {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [{
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
|
||||
name: otherUserId,
|
||||
value: 'true',
|
||||
}],
|
||||
};
|
||||
|
||||
const result = makeDirectChannelVisibleIfNecessary(state, otherUserId);
|
||||
expect(result).toStrictEqual(expectedResult);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUserId);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledTimes(1);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledWith(currentUserId, expectedResult.data);
|
||||
});
|
||||
|
||||
it('makes direct channel visible when visibilty preference is false', () => {
|
||||
const preference = {value: 'false'};
|
||||
const preferenceKey = 'preference-key';
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({
|
||||
[preferenceKey]: preference,
|
||||
});
|
||||
PreferenceUtils.getPreferenceKey.mockReturnValueOnce(preferenceKey);
|
||||
|
||||
const expectedResult = {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [{
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
|
||||
name: otherUserId,
|
||||
value: 'true',
|
||||
}],
|
||||
};
|
||||
|
||||
const result = makeDirectChannelVisibleIfNecessary(state, otherUserId);
|
||||
expect(result).toStrictEqual(expectedResult);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUserId);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledTimes(1);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledWith(currentUserId, expectedResult.data);
|
||||
});
|
||||
|
||||
it('does nothing if direct channel visibility preference is true', () => {
|
||||
const preference = {value: 'true'};
|
||||
const preferenceKey = 'preference-key';
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({
|
||||
[preferenceKey]: preference,
|
||||
});
|
||||
PreferenceUtils.getPreferenceKey.mockReturnValueOnce(preferenceKey);
|
||||
|
||||
const result = makeDirectChannelVisibleIfNecessary(state, otherUserId);
|
||||
expect(result).toEqual(null);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUserId);
|
||||
expect(Client4.savePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeGroupMessageVisibleIfNecessary', () => {
|
||||
const state = {};
|
||||
const currentUserId = 'current-user-id';
|
||||
const channelId = 'channel-id';
|
||||
|
||||
CommonSelectors.getCurrentUserId = jest.fn().mockReturnValue(currentUserId);
|
||||
PreferenceSelectors.getMyPreferences = jest.fn();
|
||||
PreferenceUtils.getPreferenceKey = jest.fn();
|
||||
Client4.savePreferences = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
PreferenceSelectors.getMyPreferences.mockClear();
|
||||
PreferenceUtils.getPreferenceKey.mockClear();
|
||||
Client4.savePreferences.mockClear();
|
||||
});
|
||||
|
||||
it('makes group channel visible when visibility preference does not exist', async () => {
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({});
|
||||
|
||||
const expectedPreferenceResult = {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [{
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_GROUP_CHANNEL_SHOW,
|
||||
name: channelId,
|
||||
value: 'true',
|
||||
}],
|
||||
};
|
||||
|
||||
const result = await makeGroupMessageVisibleIfNecessary(state, channelId);
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result[1]).toStrictEqual(expectedPreferenceResult);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledTimes(1);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledWith(currentUserId, expectedPreferenceResult.data);
|
||||
});
|
||||
|
||||
it('makes group channel visible when visibilty preference is false', async () => {
|
||||
const preference = {value: 'false'};
|
||||
const preferenceKey = 'preference-key';
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({
|
||||
[preferenceKey]: preference,
|
||||
});
|
||||
PreferenceUtils.getPreferenceKey.mockReturnValueOnce(preferenceKey);
|
||||
|
||||
const expectedPreferenceResult = {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [{
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_GROUP_CHANNEL_SHOW,
|
||||
name: channelId,
|
||||
value: 'true',
|
||||
}],
|
||||
};
|
||||
|
||||
const result = await makeGroupMessageVisibleIfNecessary(state, channelId);
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result[1]).toStrictEqual(expectedPreferenceResult);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledTimes(1);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledWith(currentUserId, expectedPreferenceResult.data);
|
||||
});
|
||||
|
||||
it('does nothing if group channel visibility preference is true', async () => {
|
||||
const preference = {value: 'true'};
|
||||
const preferenceKey = 'preference-key';
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({
|
||||
[preferenceKey]: preference,
|
||||
});
|
||||
PreferenceUtils.getPreferenceKey.mockReturnValueOnce(preferenceKey);
|
||||
|
||||
const result = await makeGroupMessageVisibleIfNecessary(state, channelId);
|
||||
expect(result).toEqual(null);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId);
|
||||
expect(Client4.savePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,422 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ChannelTypes, PreferenceTypes, RoleTypes, UserTypes} from '@mm-redux/action_types';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {General, Preferences} from '@mm-redux/constants';
|
||||
import {getCurrentChannelId, getRedirectChannelNameForTeam, getChannelsNameMapInTeam} from '@mm-redux/selectors/entities/channels';
|
||||
import {getConfig} from '@mm-redux/selectors/entities/general';
|
||||
import {getMyPreferences} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getCurrentUserId, getUsers, getUserIdsInChannels} from '@mm-redux/selectors/entities/users';
|
||||
import {getChannelByName as selectChannelByName, getUserIdFromChannelName, isAutoClosed} from '@mm-redux/utils/channel_utils';
|
||||
import {getPreferenceKey} from '@mm-redux/utils/preference_utils';
|
||||
|
||||
import {ActionResult, GenericAction} from '@mm-redux/types/actions';
|
||||
import {Channel, ChannelMembership} from '@mm-redux/types/channels';
|
||||
import {PreferenceType} from '@mm-redux/types/preferences';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {UserProfile} from '@mm-redux/types/users';
|
||||
import {RelationOneToMany} from '@mm-redux/types/utilities';
|
||||
|
||||
import {isDirectChannelVisible, isGroupChannelVisible} from '@utils/channels';
|
||||
import {buildPreference} from '@utils/preferences';
|
||||
|
||||
export async function loadSidebarDirectMessagesProfiles(state: GlobalState, channels: Array<Channel>, channelMembers: Array<ChannelMembership>) {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const usersInChannel: RelationOneToMany<Channel, UserProfile> = getUserIdsInChannels(state);
|
||||
const directChannels = Object.values(channels).filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
|
||||
const prefs: Array<PreferenceType> = [];
|
||||
const promises: Array<Promise<ActionResult>> = []; //only fetch profiles that we don't have and the Direct channel should be visible
|
||||
const actions = [];
|
||||
const userIds: Array<string> = [];
|
||||
|
||||
// Prepare preferences and start fetching profiles to batch them
|
||||
directChannels.forEach((c) => {
|
||||
const profileIds = Array.from(usersInChannel[c.id] || []);
|
||||
const profilesInChannel: Array<string> = profileIds.filter((u: string) => u !== currentUserId);
|
||||
userIds.push(...profilesInChannel);
|
||||
|
||||
switch (c.type) {
|
||||
case General.DM_CHANNEL: {
|
||||
const dm = fetchDirectMessageProfileIfNeeded(state, c, channelMembers, profilesInChannel);
|
||||
if (dm.preferences.length) {
|
||||
prefs.push(...dm.preferences);
|
||||
}
|
||||
|
||||
if (dm.promise) {
|
||||
promises.push(dm.promise);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case General.GM_CHANNEL: {
|
||||
const gm = fetchGroupMessageProfilesIfNeeded(state, c, channelMembers, profilesInChannel);
|
||||
|
||||
if (gm.preferences.length) {
|
||||
prefs.push(...gm.preferences);
|
||||
}
|
||||
|
||||
if (gm.promise) {
|
||||
promises.push(gm.promise);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save preferences if there are any changes
|
||||
if (prefs.length) {
|
||||
Client4.savePreferences(currentUserId, prefs);
|
||||
actions.push({
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: prefs,
|
||||
});
|
||||
}
|
||||
|
||||
const profilesAction = await getProfilesFromPromises(promises);
|
||||
const userIdsSet: Set<string> = new Set(userIds);
|
||||
|
||||
if (profilesAction) {
|
||||
actions.push(profilesAction);
|
||||
profilesAction.data.forEach((d: any) => {
|
||||
const {users} = d.data;
|
||||
users.forEach((u: UserProfile) => userIdsSet.add(u.id));
|
||||
});
|
||||
}
|
||||
|
||||
if (userIdsSet.size > 0) {
|
||||
try {
|
||||
const statuses = await Client4.getStatusesByIds(Array.from(userIdsSet));
|
||||
if (statuses.length) {
|
||||
actions.push({
|
||||
type: UserTypes.RECEIVED_STATUSES,
|
||||
data: statuses,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// do nothing (status will get fetched later on regardless)
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function fetchMyChannel(channelId: string) {
|
||||
try {
|
||||
const data = await Client4.getChannel(channelId);
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMyChannelMember(channelId: string) {
|
||||
try {
|
||||
const data = await Client4.getMyChannelMember(channelId);
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export function markChannelAsUnread(state: GlobalState, teamId: string, channelId: string, mentions: Array<string>): Array<GenericAction> {
|
||||
const {myMembers} = state.entities.channels;
|
||||
const {currentUserId} = state.entities.users;
|
||||
|
||||
const actions: GenericAction[] = [{
|
||||
type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
amount: 1,
|
||||
},
|
||||
}, {
|
||||
type: ChannelTypes.INCREMENT_UNREAD_MSG_COUNT,
|
||||
data: {
|
||||
teamId,
|
||||
channelId,
|
||||
amount: 1,
|
||||
onlyMentions: myMembers[channelId] && myMembers[channelId].notify_props &&
|
||||
myMembers[channelId].notify_props.mark_unread === General.MENTION,
|
||||
},
|
||||
}];
|
||||
|
||||
if (mentions && mentions.indexOf(currentUserId) !== -1) {
|
||||
actions.push({
|
||||
type: ChannelTypes.INCREMENT_UNREAD_MENTION_COUNT,
|
||||
data: {
|
||||
teamId,
|
||||
channelId,
|
||||
amount: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function makeDirectChannelVisibleIfNecessary(state: GlobalState, otherUserId: string): GenericAction|null {
|
||||
const myPreferences = getMyPreferences(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
|
||||
let preference = myPreferences[getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUserId)];
|
||||
|
||||
if (!preference || preference.value === 'false') {
|
||||
preference = {
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
|
||||
name: otherUserId,
|
||||
value: 'true',
|
||||
};
|
||||
|
||||
Client4.savePreferences(currentUserId, [preference]);
|
||||
return {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [preference],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function makeGroupMessageVisibleIfNecessary(state: GlobalState, channelId: string) {
|
||||
try {
|
||||
const myPreferences = getMyPreferences(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
|
||||
let preference = myPreferences[getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId)];
|
||||
|
||||
if (!preference || preference.value === 'false') {
|
||||
preference = {
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_GROUP_CHANNEL_SHOW,
|
||||
name: channelId,
|
||||
value: 'true',
|
||||
};
|
||||
|
||||
Client4.savePreferences(currentUserId, [preference]);
|
||||
|
||||
const profilesInChannel = await fetchUsersInChannel(state, channelId);
|
||||
|
||||
return [{
|
||||
type: UserTypes.RECEIVED_BATCHED_PROFILES_IN_CHANNEL,
|
||||
data: [profilesInChannel],
|
||||
}, {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [preference],
|
||||
}];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchChannelAndMyMember(channelId: string): Promise<Array<GenericAction>> {
|
||||
const actions: Array<GenericAction> = [];
|
||||
|
||||
try {
|
||||
const [channel, member] = await Promise.all([
|
||||
Client4.getChannel(channelId),
|
||||
Client4.getMyChannelMember(channelId),
|
||||
]);
|
||||
|
||||
actions.push({
|
||||
type: ChannelTypes.RECEIVED_CHANNEL,
|
||||
data: channel,
|
||||
},
|
||||
{
|
||||
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
|
||||
data: member,
|
||||
});
|
||||
|
||||
const roles = await Client4.getRolesByNames(member.roles.split(' '));
|
||||
if (roles.length) {
|
||||
actions.push({
|
||||
type: RoleTypes.RECEIVED_ROLES,
|
||||
data: roles,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function getAddedDmUsersIfNecessary(state: GlobalState, preferences: PreferenceType[]): Promise<Array<GenericAction>> {
|
||||
const userIds: string[] = [];
|
||||
const actions: Array<GenericAction> = [];
|
||||
|
||||
for (const preference of preferences) {
|
||||
if (preference.category === Preferences.CATEGORY_DIRECT_CHANNEL_SHOW && preference.value === 'true') {
|
||||
userIds.push(preference.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (userIds.length !== 0) {
|
||||
const profiles = getUsers(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
|
||||
const needProfiles: string[] = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (!profiles[userId] && userId !== currentUserId) {
|
||||
needProfiles.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (needProfiles.length > 0) {
|
||||
const data = await Client4.getProfilesByIds(userIds);
|
||||
if (profiles.lenght) {
|
||||
actions.push({
|
||||
type: UserTypes.RECEIVED_PROFILES_LIST,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function lastChannelIdForTeam(state: GlobalState, teamId: string): string {
|
||||
const {channels, myMembers} = state.entities.channels;
|
||||
const {currentUserId} = state.entities.users;
|
||||
const {myPreferences} = state.entities.preferences;
|
||||
const lastChannelForTeam = state.views.team.lastChannelForTeam[teamId];
|
||||
const lastChannelId = lastChannelForTeam && lastChannelForTeam.length ? lastChannelForTeam[0] : '';
|
||||
const lastChannel = channels[lastChannelId];
|
||||
|
||||
const isDMVisible = lastChannel && lastChannel.type === General.DM_CHANNEL &&
|
||||
isDirectChannelVisible(currentUserId, myPreferences, lastChannel);
|
||||
|
||||
const isGMVisible = lastChannel && lastChannel.type === General.GM_CHANNEL &&
|
||||
isGroupChannelVisible(myPreferences, lastChannel);
|
||||
|
||||
if (
|
||||
myMembers[lastChannelId] &&
|
||||
lastChannel &&
|
||||
(lastChannel.team_id === teamId || isDMVisible || isGMVisible)
|
||||
) {
|
||||
return lastChannelId;
|
||||
}
|
||||
|
||||
// Fallback to default channel
|
||||
const channelsInTeam = getChannelsNameMapInTeam(state, teamId);
|
||||
const channel = selectChannelByName(channelsInTeam, getRedirectChannelNameForTeam(state, teamId));
|
||||
|
||||
if (channel) {
|
||||
return channel.id;
|
||||
}
|
||||
|
||||
// Handle case when the default channel cannot be found
|
||||
// so we need to get the first available channel of the team
|
||||
const teamChannels = Object.values(channelsInTeam);
|
||||
const firstChannel = teamChannels.length ? teamChannels[0].id : '';
|
||||
return firstChannel;
|
||||
}
|
||||
|
||||
function fetchDirectMessageProfileIfNeeded(state: GlobalState, channel: Channel, channelMembers: Array<ChannelMembership>, profilesInChannel: Array<string>) {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const myPreferences = getMyPreferences(state);
|
||||
const users = getUsers(state);
|
||||
const config = getConfig(state);
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
const otherUserId = getUserIdFromChannelName(currentUserId, channel.name);
|
||||
const otherUser = users[otherUserId];
|
||||
const dmVisible = isDirectChannelVisible(currentUserId, myPreferences, channel);
|
||||
const dmAutoClosed = isAutoClosed(config, myPreferences, channel, channel.last_post_at, otherUser ? otherUser.delete_at : 0, currentChannelId);
|
||||
const member = channelMembers.find((cm) => cm.channel_id === channel.id);
|
||||
const dmIsUnread = member ? member.mention_count > 0 : false;
|
||||
const dmFetchProfile = dmIsUnread || (dmVisible && !dmAutoClosed);
|
||||
const preferences = [];
|
||||
|
||||
// when then DM is hidden but has new messages
|
||||
if ((!dmVisible || dmAutoClosed) && dmIsUnread) {
|
||||
preferences.push(buildPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, currentUserId, otherUserId));
|
||||
preferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
|
||||
}
|
||||
|
||||
if (dmFetchProfile && !profilesInChannel.includes(otherUserId) && otherUserId !== currentUserId) {
|
||||
return {
|
||||
preferences,
|
||||
promise: fetchUsersInChannel(state, channel.id),
|
||||
};
|
||||
}
|
||||
|
||||
return {preferences};
|
||||
}
|
||||
|
||||
function fetchGroupMessageProfilesIfNeeded(state: GlobalState, channel: Channel, channelMembers: Array<ChannelMembership>, profilesInChannel: Array<string>) {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const myPreferences = getMyPreferences(state);
|
||||
const config = getConfig(state);
|
||||
const gmVisible = isGroupChannelVisible(myPreferences, channel);
|
||||
const gmAutoClosed = isAutoClosed(config, myPreferences, channel, channel.last_post_at, 0);
|
||||
const channelMember = channelMembers.find((cm) => cm.channel_id === channel.id);
|
||||
let hasMentions = false;
|
||||
let isUnread = false;
|
||||
|
||||
if (channelMember) {
|
||||
hasMentions = channelMember.mention_count > 0;
|
||||
isUnread = channelMember.msg_count < channel.total_msg_count;
|
||||
}
|
||||
|
||||
const gmIsUnread = hasMentions || isUnread;
|
||||
const gmFetchProfile = gmIsUnread || (gmVisible && !gmAutoClosed);
|
||||
const preferences = [];
|
||||
|
||||
// when then GM is hidden but has new messages
|
||||
if ((!gmVisible || gmAutoClosed) && gmIsUnread) {
|
||||
preferences.push(buildPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, currentUserId, channel.id));
|
||||
preferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
|
||||
}
|
||||
|
||||
if (gmFetchProfile && !profilesInChannel.length) {
|
||||
return {
|
||||
preferences,
|
||||
promise: fetchUsersInChannel(state, channel.id),
|
||||
};
|
||||
}
|
||||
|
||||
return {preferences};
|
||||
}
|
||||
|
||||
async function fetchUsersInChannel(state: GlobalState, channelId: string): Promise<ActionResult> {
|
||||
try {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const profiles = await Client4.getProfilesInChannel(channelId);
|
||||
|
||||
// When fetching profiles in channels we exclude our own user
|
||||
const users = profiles.filter((p: UserProfile) => p.id !== currentUserId);
|
||||
const data = {
|
||||
channelId,
|
||||
users,
|
||||
};
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
async function getProfilesFromPromises(promises: Array<Promise<ActionResult>>): Promise<GenericAction | null> {
|
||||
// Get the profiles returned by the promises
|
||||
if (!promises.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Promise.all(promises);
|
||||
const data = result.filter((p: any) => !p.error);
|
||||
|
||||
return {
|
||||
type: UserTypes.RECEIVED_BATCHED_PROFILES_IN_CHANNEL,
|
||||
data,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Keyboard, Platform} from 'react-native';
|
||||
import {Platform} from 'react-native';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
|
||||
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 {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import Store from '@store/store';
|
||||
import {NavigationTypes} from '@constants';
|
||||
import store from 'app/store';
|
||||
import EphemeralStore from 'app/store/ephemeral_store';
|
||||
|
||||
function getThemeFromState() {
|
||||
const state = Store.redux?.getState() || {};
|
||||
const state = store.getState();
|
||||
|
||||
return getTheme(state);
|
||||
}
|
||||
@@ -22,87 +20,55 @@ function getThemeFromState() {
|
||||
export function resetToChannel(passProps = {}) {
|
||||
const theme = getThemeFromState();
|
||||
|
||||
EphemeralStore.clearNavigationComponents();
|
||||
|
||||
const stack = {
|
||||
children: [{
|
||||
component: {
|
||||
id: NavigationTypes.CHANNEL_SCREEN,
|
||||
name: NavigationTypes.CHANNEL_SCREEN,
|
||||
passProps,
|
||||
options: {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
backButton: {
|
||||
visible: false,
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
let platformStack = {stack};
|
||||
if (Platform.OS === 'android') {
|
||||
platformStack = {
|
||||
sideMenu: {
|
||||
left: {
|
||||
component: {
|
||||
id: 'MainSidebar',
|
||||
name: 'MainSidebar',
|
||||
},
|
||||
},
|
||||
center: {
|
||||
stack,
|
||||
},
|
||||
right: {
|
||||
component: {
|
||||
id: 'SettingsSidebar',
|
||||
name: 'SettingsSidebar',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
...platformStack,
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
name: 'Channel',
|
||||
passProps,
|
||||
options: {
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resetToSelectServer(allowOtherServers) {
|
||||
const theme = Preferences.THEMES.default;
|
||||
|
||||
EphemeralStore.clearNavigationComponents();
|
||||
const theme = getThemeFromState();
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: 'SelectServer',
|
||||
name: 'SelectServer',
|
||||
passProps: {
|
||||
allowOtherServers,
|
||||
},
|
||||
options: {
|
||||
layout: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
@@ -129,7 +95,7 @@ export function resetToTeams(name, title, passProps = {}, options = {}) {
|
||||
const theme = getThemeFromState();
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
@@ -150,14 +116,11 @@ export function resetToTeams(name, title, passProps = {}, options = {}) {
|
||||
},
|
||||
};
|
||||
|
||||
EphemeralStore.clearNavigationComponents();
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -173,12 +136,7 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
popGesture: true,
|
||||
sideMenu: {
|
||||
left: {enabled: false},
|
||||
right: {enabled: false},
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
@@ -186,7 +144,6 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
testID: 'screen.back.button',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
@@ -200,7 +157,6 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
|
||||
|
||||
Navigation.push(componentId, {
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -228,19 +184,11 @@ 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 = {
|
||||
modalPresentationStyle: Platform.select({ios: 'pageSheet', android: 'none'}),
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
@@ -264,12 +212,10 @@ export function showModal(name, title, passProps = {}, options = {}) {
|
||||
},
|
||||
};
|
||||
|
||||
EphemeralStore.addNavigationModal(name);
|
||||
Navigation.showModal({
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -286,7 +232,6 @@ export function showModalOverCurrentContext(name, passProps = {}, options = {})
|
||||
modalPresentationStyle: 'overCurrentContext',
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
componentBackgroundColor: 'transparent',
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
@@ -294,7 +239,6 @@ export function showModalOverCurrentContext(name, passProps = {}, options = {})
|
||||
},
|
||||
animations: {
|
||||
showModal: {
|
||||
waitForRender: true,
|
||||
enabled: animationsEnabled,
|
||||
alpha: {
|
||||
from: 0,
|
||||
@@ -326,26 +270,16 @@ export function showSearchModal(initialValue = '') {
|
||||
visible: false,
|
||||
height: 0,
|
||||
},
|
||||
...Platform.select({
|
||||
ios: {
|
||||
modalPresentationStyle: 'pageSheet',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
showModal(name, title, passProps, options);
|
||||
}
|
||||
|
||||
export async function dismissModal(options = {}) {
|
||||
if (!EphemeralStore.hasModalsOpened()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
try {
|
||||
await Navigation.dismissModal(componentId, options);
|
||||
EphemeralStore.removeNavigationModal(componentId);
|
||||
} catch (error) {
|
||||
// RNN returns a promise rejection if there is no modal to
|
||||
// dismiss. We'll do nothing in this case.
|
||||
@@ -353,19 +287,31 @@ export async function dismissModal(options = {}) {
|
||||
}
|
||||
|
||||
export async function dismissAllModals(options = {}) {
|
||||
if (!EphemeralStore.hasModalsOpened()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Navigation.dismissAllModals(options);
|
||||
EphemeralStore.clearNavigationModals();
|
||||
} catch (error) {
|
||||
// RNN returns a promise rejection if there are no modals to
|
||||
// dismiss. We'll do nothing in this case.
|
||||
}
|
||||
}
|
||||
|
||||
export function peek(name, passProps = {}, options = {}) {
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
const defaultOptions = {
|
||||
preview: {
|
||||
commit: false,
|
||||
},
|
||||
};
|
||||
|
||||
Navigation.push(componentId, {
|
||||
component: {
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function setButtons(componentId, buttons = {leftButtons: [], rightButtons: []}) {
|
||||
const options = {
|
||||
topBar: {
|
||||
@@ -382,10 +328,6 @@ export function mergeNavigationOptions(componentId, options) {
|
||||
|
||||
export function showOverlay(name, passProps, options = {}) {
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
componentBackgroundColor: 'transparent',
|
||||
},
|
||||
overlay: {
|
||||
interceptTouchOutside: false,
|
||||
},
|
||||
@@ -408,69 +350,3 @@ export async function dismissOverlay(componentId) {
|
||||
// this componentId to dismiss. We'll do nothing in this case.
|
||||
}
|
||||
}
|
||||
|
||||
export function openMainSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(componentId, {
|
||||
sideMenu: {
|
||||
left: {visible: true},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function closeMainSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
sideMenu: {
|
||||
left: {visible: false},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function enableMainSideMenu(enabled, visible = true) {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
sideMenu: {
|
||||
left: {enabled, visible},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function openSettingsSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
sideMenu: {
|
||||
right: {visible: true},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function closeSettingsSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
|
||||
sideMenu: {
|
||||
right: {visible: false},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,32 +3,20 @@
|
||||
|
||||
import {Platform} from 'react-native';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import merge from 'deepmerge';
|
||||
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import Preferences from 'mattermost-redux/constants/preferences';
|
||||
|
||||
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';
|
||||
import EphemeralStore from 'app/store/ephemeral_store';
|
||||
import * as NavigationActions from 'app/actions/navigation';
|
||||
|
||||
jest.unmock('@actions/navigation');
|
||||
jest.mock('@store/ephemeral_store', () => ({
|
||||
jest.unmock('app/actions/navigation');
|
||||
jest.mock('app/store/ephemeral_store', () => ({
|
||||
getNavigationTopComponentId: jest.fn(),
|
||||
clearNavigationComponents: jest.fn(),
|
||||
addNavigationModal: jest.fn(),
|
||||
hasModalsOpened: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const store = mockStore(intitialState);
|
||||
Store.redux = store;
|
||||
|
||||
describe('@actions/navigation', () => {
|
||||
describe('app/actions/navigation', () => {
|
||||
const topComponentId = 'top-component-id';
|
||||
const name = 'name';
|
||||
const title = 'title';
|
||||
@@ -49,12 +37,11 @@ describe('@actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: 'Channel',
|
||||
name: 'Channel',
|
||||
passProps,
|
||||
options: {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
@@ -63,12 +50,15 @@ describe('@actions/navigation', () => {
|
||||
visible: false,
|
||||
height: 0,
|
||||
backButton: {
|
||||
visible: false,
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -90,16 +80,11 @@ describe('@actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: 'SelectServer',
|
||||
name: 'SelectServer',
|
||||
passProps: {
|
||||
allowOtherServers,
|
||||
},
|
||||
options: {
|
||||
layout: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
@@ -130,7 +115,7 @@ describe('@actions/navigation', () => {
|
||||
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
@@ -156,7 +141,6 @@ describe('@actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -175,12 +159,7 @@ describe('@actions/navigation', () => {
|
||||
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
popGesture: true,
|
||||
sideMenu: {
|
||||
left: {enabled: false},
|
||||
right: {enabled: false},
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
@@ -188,7 +167,6 @@ describe('@actions/navigation', () => {
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
testID: 'screen.back.button',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
@@ -202,7 +180,6 @@ describe('@actions/navigation', () => {
|
||||
|
||||
const expectedLayout = {
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -235,9 +212,8 @@ describe('@actions/navigation', () => {
|
||||
const showModal = jest.spyOn(Navigation, 'showModal');
|
||||
|
||||
const defaultOptions = {
|
||||
modalPresentationStyle: Platform.select({ios: 'pageSheet', android: 'none'}),
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
@@ -265,7 +241,6 @@ describe('@actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -287,7 +262,6 @@ describe('@actions/navigation', () => {
|
||||
modalPresentationStyle: 'overCurrentContext',
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
componentBackgroundColor: 'transparent',
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
@@ -295,7 +269,6 @@ describe('@actions/navigation', () => {
|
||||
},
|
||||
animations: {
|
||||
showModal: {
|
||||
waitForRender: true,
|
||||
enabled: animationsEnabled,
|
||||
alpha: {
|
||||
from: 0,
|
||||
@@ -314,9 +287,8 @@ describe('@actions/navigation', () => {
|
||||
},
|
||||
};
|
||||
const showModalOptions = {
|
||||
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
@@ -345,7 +317,6 @@ describe('@actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(showModalOptions, defaultOptions),
|
||||
@@ -372,9 +343,8 @@ describe('@actions/navigation', () => {
|
||||
},
|
||||
};
|
||||
const defaultOptions = {
|
||||
modalPresentationStyle: Platform.select({ios: 'pageSheet', android: 'none'}),
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
@@ -402,7 +372,6 @@ describe('@actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: showSearchModalName,
|
||||
name: showSearchModalName,
|
||||
passProps: showSearchModalPassProps,
|
||||
options: merge(defaultOptions, showSearchModalOptions),
|
||||
@@ -429,6 +398,27 @@ describe('@actions/navigation', () => {
|
||||
expect(dismissAllModals).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
test('peek should call Navigation.push', async () => {
|
||||
const push = jest.spyOn(Navigation, 'push');
|
||||
|
||||
const defaultOptions = {
|
||||
preview: {
|
||||
commit: false,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedLayout = {
|
||||
component: {
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
};
|
||||
|
||||
await NavigationActions.peek(name, passProps, options);
|
||||
expect(push).toHaveBeenCalledWith(topComponentId, expectedLayout);
|
||||
});
|
||||
|
||||
test('mergeNavigationOptions should call Navigation.mergeOptions', () => {
|
||||
const mergeOptions = jest.spyOn(Navigation, 'mergeOptions');
|
||||
|
||||
@@ -457,10 +447,6 @@ describe('@actions/navigation', () => {
|
||||
const showOverlay = jest.spyOn(Navigation, 'showOverlay');
|
||||
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
componentBackgroundColor: 'transparent',
|
||||
},
|
||||
overlay: {
|
||||
interceptTouchOutside: false,
|
||||
},
|
||||
@@ -484,15 +470,4 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,126 +5,307 @@ import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
import {ChannelTypes, RoleTypes, GroupTypes} from '@mm-redux/action_types';
|
||||
import {UserTypes} from 'mattermost-redux/action_types';
|
||||
import {
|
||||
fetchMyChannelsAndMembers,
|
||||
getChannelByNameAndTeamName,
|
||||
joinChannel,
|
||||
markChannelAsRead,
|
||||
markChannelAsViewed,
|
||||
leaveChannel as serviceLeaveChannel,
|
||||
} from '@mm-redux/actions/channels';
|
||||
import {savePreferences} from '@mm-redux/actions/preferences';
|
||||
import {getLicense} from '@mm-redux/selectors/entities/general';
|
||||
import {selectTeam} from '@mm-redux/actions/teams';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {General, Preferences} from '@mm-redux/constants';
|
||||
import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts';
|
||||
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
|
||||
selectChannel,
|
||||
getChannelStats,
|
||||
} from 'mattermost-redux/actions/channels';
|
||||
import {
|
||||
getPosts,
|
||||
getPostsBefore,
|
||||
getPostsSince,
|
||||
getPostThread,
|
||||
} from 'mattermost-redux/actions/posts';
|
||||
import {getFilesForPost} from 'mattermost-redux/actions/files';
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {getTeamMembersByIds, selectTeam} from 'mattermost-redux/actions/teams';
|
||||
import {getProfilesInChannel} from 'mattermost-redux/actions/users';
|
||||
import {General, Preferences} from 'mattermost-redux/constants';
|
||||
import {getPostIdsInChannel} from 'mattermost-redux/selectors/entities/posts';
|
||||
import {
|
||||
getChannel,
|
||||
getCurrentChannelId,
|
||||
getMyChannelMember,
|
||||
getRedirectChannelNameForTeam,
|
||||
getChannelsNameMapInTeam,
|
||||
getMyChannelMemberships,
|
||||
isManuallyUnread,
|
||||
} from '@mm-redux/selectors/entities/channels';
|
||||
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
|
||||
import {getTeamByName, getCurrentTeam} from '@mm-redux/selectors/entities/teams';
|
||||
} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentTeamId, getTeamByName} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
import {getChannelByName as selectChannelByName, getChannelsIdForTeam} from '@mm-redux/utils/channel_utils';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import telemetry from 'app/telemetry';
|
||||
|
||||
import {lastChannelIdForTeam, loadSidebarDirectMessagesProfiles} from '@actions/helpers/channels';
|
||||
import {getPosts, getPostsBefore, getPostsSince, loadUnreadChannelPosts} from '@actions/views/post';
|
||||
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_draft';
|
||||
import {getChannelReachable} from '@selectors/channel';
|
||||
import telemetry from '@telemetry';
|
||||
import {isDirectChannelVisible, isGroupChannelVisible, getChannelSinceValue} from '@utils/channels';
|
||||
import {isPendingPost} from '@utils/general';
|
||||
import {
|
||||
getChannelByName,
|
||||
getDirectChannelName,
|
||||
getUserIdFromChannelName,
|
||||
isDirectChannel,
|
||||
isGroupChannel,
|
||||
getChannelByName as getChannelByNameSelector,
|
||||
} from 'mattermost-redux/utils/channel_utils';
|
||||
import EventEmitter from 'mattermost-redux/utils/event_emitter';
|
||||
import {getLastCreateAt} from 'mattermost-redux/utils/post_utils';
|
||||
import {getPreferencesByCategory} from 'mattermost-redux/utils/preference_utils';
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from 'app/constants/post_textbox';
|
||||
import {isDirectChannelVisible, isGroupChannelVisible} from 'app/utils/channels';
|
||||
|
||||
export function loadChannelsByTeamName(teamName, errorHandler) {
|
||||
const MAX_POST_TRIES = 3;
|
||||
|
||||
export function loadChannelsIfNecessary(teamId) {
|
||||
return async (dispatch) => {
|
||||
await dispatch(fetchMyChannelsAndMembers(teamId));
|
||||
};
|
||||
}
|
||||
|
||||
export function loadChannelsByTeamName(teamName) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {currentTeamId} = state.entities.teams;
|
||||
const team = getTeamByName(state, teamName);
|
||||
|
||||
if (teamName) {
|
||||
const team = getTeamByName(state, teamName);
|
||||
if (team && team.id !== currentTeamId) {
|
||||
await dispatch(fetchMyChannelsAndMembers(team.id));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!team && errorHandler) {
|
||||
errorHandler();
|
||||
return {error: true};
|
||||
export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {currentUserId, profilesInChannel} = state.entities.users;
|
||||
const {channels, myMembers} = state.entities.channels;
|
||||
const {myPreferences} = state.entities.preferences;
|
||||
const {membersInTeam} = state.entities.teams;
|
||||
const dmPrefs = getPreferencesByCategory(myPreferences, Preferences.CATEGORY_DIRECT_CHANNEL_SHOW);
|
||||
const gmPrefs = getPreferencesByCategory(myPreferences, Preferences.CATEGORY_GROUP_CHANNEL_SHOW);
|
||||
const members = [];
|
||||
const loadProfilesForChannels = [];
|
||||
const prefs = [];
|
||||
|
||||
function buildPref(name) {
|
||||
return {
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
|
||||
name,
|
||||
value: 'true',
|
||||
};
|
||||
}
|
||||
|
||||
// Find DM's and GM's that need to be shown
|
||||
const directChannels = Object.values(channels).filter((c) => (isDirectChannel(c) || isGroupChannel(c)));
|
||||
directChannels.forEach((channel) => {
|
||||
const member = myMembers[channel.id];
|
||||
if (isDirectChannel(channel) && !isDirectChannelVisible(currentUserId, myPreferences, channel) && member && member.mention_count > 0) {
|
||||
const teammateId = getUserIdFromChannelName(currentUserId, channel.name);
|
||||
let pref = dmPrefs.get(teammateId);
|
||||
if (pref) {
|
||||
pref = {...pref, value: 'true'};
|
||||
} else {
|
||||
pref = buildPref(teammateId);
|
||||
}
|
||||
dmPrefs.set(teammateId, pref);
|
||||
prefs.push(pref);
|
||||
} else if (isGroupChannel(channel) && !isGroupChannelVisible(myPreferences, channel) && member && (member.mention_count > 0 || member.msg_count < channel.total_msg_count)) {
|
||||
const id = channel.id;
|
||||
let pref = gmPrefs.get(id);
|
||||
if (pref) {
|
||||
pref = {...pref, value: 'true'};
|
||||
} else {
|
||||
pref = buildPref(id);
|
||||
}
|
||||
gmPrefs.set(id, pref);
|
||||
prefs.push(pref);
|
||||
}
|
||||
});
|
||||
|
||||
if (team && team.id !== currentTeamId) {
|
||||
await dispatch(fetchMyChannelsAndMembers(team.id));
|
||||
if (prefs.length) {
|
||||
savePreferences(currentUserId, prefs)(dispatch, getState);
|
||||
}
|
||||
|
||||
for (const [key, pref] of dmPrefs) {
|
||||
if (pref.value === 'true') {
|
||||
members.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
for (const [key, pref] of gmPrefs) {
|
||||
//only load the profiles in channels if we don't already have them
|
||||
if (pref.value === 'true' && !profilesInChannel[key]) {
|
||||
loadProfilesForChannels.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadProfilesForChannels.length) {
|
||||
for (let i = 0; i < loadProfilesForChannels.length; i++) {
|
||||
const channelId = loadProfilesForChannels[i];
|
||||
getProfilesInChannel(channelId, 0)(dispatch, getState);
|
||||
}
|
||||
}
|
||||
|
||||
let membersToLoad = members;
|
||||
if (membersInTeam[teamId]) {
|
||||
membersToLoad = members.filter((m) => !membersInTeam[teamId].hasOwnProperty(m));
|
||||
}
|
||||
|
||||
if (membersToLoad.length) {
|
||||
getTeamMembersByIds(teamId, membersToLoad)(dispatch, getState);
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
const channelName = getDirectChannelName(currentUserId, members[i]);
|
||||
const channel = getChannelByName(channels, channelName);
|
||||
if (channel) {
|
||||
actions.push({
|
||||
type: UserTypes.RECEIVED_PROFILE_IN_CHANNEL,
|
||||
data: {id: channel.id, user_id: members[i]},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (actions.length) {
|
||||
dispatch(batchActions(actions), getState);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPostsIfNecessaryWithRetry(channelId) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const postIds = getPostIdsInChannel(state, channelId);
|
||||
const {posts} = state.entities.posts;
|
||||
const postsIds = getPostIdsInChannel(state, channelId);
|
||||
const actions = [];
|
||||
|
||||
const time = Date.now();
|
||||
|
||||
let loadMorePostsVisible = true;
|
||||
let postAction;
|
||||
if (!postIds || postIds.length < ViewTypes.POST_VISIBILITY_CHUNK_SIZE) {
|
||||
let received;
|
||||
if (!postsIds || postsIds.length < ViewTypes.POST_VISIBILITY_CHUNK_SIZE) {
|
||||
// Get the first page of posts if it appears we haven't gotten it yet, like the webapp
|
||||
postAction = getPosts(channelId);
|
||||
} else {
|
||||
const since = getChannelSinceValue(state, channelId, postIds);
|
||||
postAction = getPostsSince(channelId, since);
|
||||
}
|
||||
received = await retryGetPostsAction(getPosts(channelId), dispatch, getState);
|
||||
|
||||
const received = await dispatch(fetchPostActionWithRetry(postAction));
|
||||
if (received?.order) {
|
||||
const count = received.order.length;
|
||||
loadMorePostsVisible = count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
actions.push({
|
||||
type: ViewTypes.SET_INITIAL_POST_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
count,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const lastConnectAt = state.websocket?.lastConnectAt || 0;
|
||||
const lastGetPosts = state.views.channel.lastGetPosts[channelId];
|
||||
|
||||
let since;
|
||||
if (lastGetPosts && lastGetPosts < lastConnectAt) {
|
||||
// Since the websocket disconnected, we may have missed some posts since then
|
||||
since = lastGetPosts;
|
||||
} else {
|
||||
// Trust that we've received all posts since the last time the websocket disconnected
|
||||
// so just get any that have changed since the latest one we've received
|
||||
const postsForChannel = postsIds.map((id) => posts[id]);
|
||||
since = getLastCreateAt(postsForChannel);
|
||||
}
|
||||
|
||||
received = await retryGetPostsAction(getPostsSince(channelId, since), dispatch, getState);
|
||||
|
||||
if (received?.order) {
|
||||
const count = received.order.length;
|
||||
loadMorePostsVisible = postsIds.length + count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
actions.push({
|
||||
type: ViewTypes.SET_INITIAL_POST_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
count: postsIds.length + count,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (received) {
|
||||
actions.push({
|
||||
type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
|
||||
channelId,
|
||||
time,
|
||||
},
|
||||
setChannelRetryFailed(false));
|
||||
|
||||
if (received?.order) {
|
||||
const count = received.order.length;
|
||||
loadMorePostsVisible = count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
actions.push(setLoadMorePostsVisible(loadMorePostsVisible));
|
||||
dispatch(batchActions(actions, 'BATCH_LOAD_POSTS_IN_CHANNEL'));
|
||||
dispatch(batchActions(actions));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPostActionWithRetry(action, maxTries = MAX_RETRIES) {
|
||||
return async (dispatch) => {
|
||||
for (let i = 0; i <= maxTries; i++) {
|
||||
const {data} = await dispatch(action); // eslint-disable-line no-await-in-loop
|
||||
export async function retryGetPostsAction(action, dispatch, getState, maxTries = MAX_POST_TRIES) {
|
||||
for (let i = 0; i < maxTries; i++) {
|
||||
const {data} = await dispatch(action); // eslint-disable-line no-await-in-loop
|
||||
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
if (data) {
|
||||
dispatch(setChannelRetryFailed(false));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(setChannelRetryFailed(true));
|
||||
dispatch(setChannelRetryFailed(true));
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
export function loadFilesForPostIfNecessary(postId) {
|
||||
return async (dispatch, getState) => {
|
||||
const {files} = getState().entities;
|
||||
const fileIdsForPost = files.fileIdsByPostId[postId];
|
||||
|
||||
if (!fileIdsForPost?.length) {
|
||||
await dispatch(getFilesForPost(postId));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadThreadIfNecessary(rootId) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {posts, postsInThread} = state.entities.posts;
|
||||
const threadPosts = postsInThread[rootId];
|
||||
|
||||
if (!posts[rootId] || !threadPosts) {
|
||||
dispatch(getPostThread(rootId));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function selectInitialChannel(teamId) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const channelId = lastChannelIdForTeam(state, teamId);
|
||||
const {channels, myMembers} = state.entities.channels;
|
||||
const {currentUserId} = state.entities.users;
|
||||
const {myPreferences} = state.entities.preferences;
|
||||
const lastChannelForTeam = state.views.team.lastChannelForTeam[teamId];
|
||||
const lastChannelId = lastChannelForTeam && lastChannelForTeam.length ? lastChannelForTeam[0] : '';
|
||||
const lastChannel = channels[lastChannelId];
|
||||
|
||||
dispatch(handleSelectChannel(channelId));
|
||||
const isDMVisible = lastChannel && lastChannel.type === General.DM_CHANNEL &&
|
||||
isDirectChannelVisible(currentUserId, myPreferences, lastChannel);
|
||||
|
||||
const isGMVisible = lastChannel && lastChannel.type === General.GM_CHANNEL &&
|
||||
isGroupChannelVisible(myPreferences, lastChannel);
|
||||
|
||||
if (
|
||||
myMembers[lastChannelId] &&
|
||||
lastChannel &&
|
||||
(lastChannel.team_id === teamId || isDMVisible || isGMVisible)
|
||||
) {
|
||||
dispatch(handleSelectChannel(lastChannelId));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(selectDefaultChannel(teamId));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,6 +331,7 @@ export function selectPenultimateChannel(teamId) {
|
||||
lastChannel.delete_at === 0 &&
|
||||
(lastChannel.team_id === teamId || isDMVisible || isGMVisible)
|
||||
) {
|
||||
dispatch(setChannelLoading(true));
|
||||
dispatch(handleSelectChannel(lastChannelId));
|
||||
return;
|
||||
}
|
||||
@@ -163,7 +345,8 @@ export function selectDefaultChannel(teamId) {
|
||||
const state = getState();
|
||||
|
||||
const channelsInTeam = getChannelsNameMapInTeam(state, teamId);
|
||||
const channel = selectChannelByName(channelsInTeam, getRedirectChannelNameForTeam(state, teamId));
|
||||
const channel = getChannelByNameSelector(channelsInTeam, getRedirectChannelNameForTeam(state, teamId));
|
||||
|
||||
let channelId;
|
||||
if (channel) {
|
||||
channelId = channel.id;
|
||||
@@ -181,98 +364,69 @@ export function selectDefaultChannel(teamId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function handleSelectChannel(channelId) {
|
||||
export function handleSelectChannel(channelId, fromPushNotification = false) {
|
||||
return async (dispatch, getState) => {
|
||||
const dt = Date.now();
|
||||
const state = getState();
|
||||
const {channels, currentChannelId, myMembers} = state.entities.channels;
|
||||
const {currentTeamId} = state.entities.teams;
|
||||
const channel = channels[channelId];
|
||||
const member = myMembers[channelId];
|
||||
const channel = getChannel(state, channelId);
|
||||
const currentTeamId = getCurrentTeamId(state);
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
const sameChannel = channelId === currentChannelId;
|
||||
const member = getMyChannelMember(state, channelId);
|
||||
|
||||
if (channel) {
|
||||
dispatch(setLoadMorePostsVisible(true));
|
||||
|
||||
// If the app is open from push notification, we already fetched the posts.
|
||||
if (!fromPushNotification) {
|
||||
dispatch(loadPostsIfNecessaryWithRetry(channelId));
|
||||
|
||||
let previousChannelId = null;
|
||||
if (currentChannelId !== channelId) {
|
||||
previousChannelId = currentChannelId;
|
||||
}
|
||||
|
||||
const actions = markAsViewedAndReadBatch(state, channelId, previousChannelId);
|
||||
actions.push({
|
||||
type: ChannelTypes.SELECT_CHANNEL,
|
||||
data: channelId,
|
||||
extra: {
|
||||
channel,
|
||||
member,
|
||||
teamId: channel.team_id || currentTeamId,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_SWITCH_CHANNEL'));
|
||||
|
||||
console.log('channel switch to', channel?.display_name, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
|
||||
}
|
||||
|
||||
let previousChannelId;
|
||||
if (!fromPushNotification && !sameChannel) {
|
||||
previousChannelId = currentChannelId;
|
||||
}
|
||||
|
||||
const actions = [
|
||||
selectChannel(channelId),
|
||||
getChannelStats(channelId),
|
||||
setChannelDisplayName(channel.display_name),
|
||||
setInitialPostVisibility(channelId),
|
||||
setChannelLoading(false),
|
||||
setLastChannelForTeam(currentTeamId, channelId),
|
||||
selectChannelWithMember(channelId, channel, member),
|
||||
];
|
||||
|
||||
dispatch(batchActions(actions));
|
||||
dispatch(markChannelViewedAndRead(channelId, previousChannelId));
|
||||
};
|
||||
}
|
||||
|
||||
export function handleSelectChannelByName(channelName, teamName, errorHandler) {
|
||||
export function handleSelectChannelByName(channelName, teamName) {
|
||||
return async (dispatch, getState) => {
|
||||
let state = getState();
|
||||
const state = getState();
|
||||
const {teams: currentTeams, currentTeamId} = state.entities.teams;
|
||||
const currentTeam = currentTeams[currentTeamId];
|
||||
const currentTeamName = currentTeam?.name;
|
||||
const response = await dispatch(getChannelByNameAndTeamName(teamName || currentTeamName, channelName));
|
||||
const {error, data: channel} = response;
|
||||
const {data: channel} = await dispatch(getChannelByNameAndTeamName(teamName || currentTeamName, channelName));
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
|
||||
state = getState();
|
||||
const reachable = getChannelReachable(state, channelName, teamName);
|
||||
|
||||
if (!reachable && errorHandler) {
|
||||
errorHandler();
|
||||
}
|
||||
|
||||
// Fallback to API response error, if any.
|
||||
if (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
if (teamName && teamName !== currentTeamName) {
|
||||
const team = getTeamByName(state, teamName);
|
||||
dispatch(selectTeam(team));
|
||||
}
|
||||
|
||||
if (channel && currentChannelId !== channel.id) {
|
||||
if (channel.type === General.OPEN_CHANNEL) {
|
||||
const myMemberships = getMyChannelMemberships(state);
|
||||
if (!myMemberships[channel.id]) {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
console.log('joining channel', channel?.display_name, channel.id); //eslint-disable-line
|
||||
const result = await dispatch(joinChannel(currentUserId, teamName, channel.id));
|
||||
if (result.error || !result.data || !result.data.channel) {
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch(handleSelectChannel(channel.id));
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export function handlePostDraftChanged(channelId, draft) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
if (state.views.channel.drafts[channelId]?.draft !== draft) {
|
||||
dispatch({
|
||||
type: ViewTypes.POST_DRAFT_CHANGED,
|
||||
channelId,
|
||||
draft,
|
||||
});
|
||||
}
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ViewTypes.POST_DRAFT_CHANGED,
|
||||
channelId,
|
||||
draft,
|
||||
}, getState);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -288,105 +442,20 @@ export function insertToDraft(value) {
|
||||
}
|
||||
|
||||
export function markChannelViewedAndRead(channelId, previousChannelId, markOnServer = true) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const actions = markAsViewedAndReadBatch(state, channelId, previousChannelId, markOnServer);
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_MARK_CHANNEL_VIEWED_AND_READ'));
|
||||
return (dispatch) => {
|
||||
dispatch(markChannelAsRead(channelId, previousChannelId, markOnServer));
|
||||
dispatch(markChannelAsViewed(channelId, previousChannelId));
|
||||
};
|
||||
}
|
||||
|
||||
export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', markOnServer = true) {
|
||||
const actions = [];
|
||||
const {channels, myMembers} = state.entities.channels;
|
||||
const channel = channels[channelId];
|
||||
const member = myMembers[channelId];
|
||||
const prevMember = myMembers[prevChannelId];
|
||||
const prevChanManuallyUnread = isManuallyUnread(state, prevChannelId);
|
||||
const prevChannel = (!prevChanManuallyUnread && prevChannelId) ? channels[prevChannelId] : null; // May be null since prevChannelId is optional
|
||||
|
||||
if (markOnServer) {
|
||||
Client4.viewMyChannel(channelId, prevChanManuallyUnread ? '' : prevChannelId).catch(() => {
|
||||
// do nothing just adding the handler to avoid the warning
|
||||
});
|
||||
}
|
||||
|
||||
if (member) {
|
||||
actions.push({
|
||||
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
|
||||
data: {...member, last_viewed_at: Date.now()},
|
||||
});
|
||||
|
||||
if (isManuallyUnread(state, channelId)) {
|
||||
actions.push({
|
||||
type: ChannelTypes.REMOVE_MANUALLY_UNREAD,
|
||||
data: {channelId},
|
||||
});
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
const unreadMessageCount = channel.total_msg_count - member.msg_count;
|
||||
actions.push({
|
||||
type: ChannelTypes.SET_UNREAD_MSG_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
count: unreadMessageCount,
|
||||
},
|
||||
}, {
|
||||
type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT,
|
||||
data: {
|
||||
teamId: channel.team_id,
|
||||
channelId,
|
||||
amount: unreadMessageCount,
|
||||
},
|
||||
}, {
|
||||
type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT,
|
||||
data: {
|
||||
teamId: channel.team_id,
|
||||
channelId,
|
||||
amount: member.mention_count,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (prevMember) {
|
||||
if (!prevChanManuallyUnread) {
|
||||
actions.push({
|
||||
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
|
||||
data: {...prevMember, last_viewed_at: Date.now()},
|
||||
});
|
||||
}
|
||||
|
||||
if (prevChannel) {
|
||||
actions.push({
|
||||
type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT,
|
||||
data: {
|
||||
teamId: prevChannel.team_id,
|
||||
channelId: prevChannelId,
|
||||
amount: prevChannel.total_msg_count - prevMember.msg_count,
|
||||
},
|
||||
}, {
|
||||
type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT,
|
||||
data: {
|
||||
teamId: prevChannel.team_id,
|
||||
channelId: prevChannelId,
|
||||
amount: prevMember.mention_count,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function markChannelViewedAndReadOnReconnect(channelId) {
|
||||
return (dispatch, getState) => {
|
||||
if (isManuallyUnread(getState(), channelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(markChannelViewedAndRead(channelId));
|
||||
dispatch(markChannelAsRead(channelId));
|
||||
dispatch(markChannelAsViewed(channelId));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -452,16 +521,10 @@ export function closeGMChannel(channel) {
|
||||
}
|
||||
|
||||
export function refreshChannelWithRetry(channelId) {
|
||||
return async (dispatch) => {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch(setChannelRefreshing(true));
|
||||
const posts = await dispatch(fetchPostActionWithRetry(getPosts(channelId)));
|
||||
const actions = [setChannelRefreshing(false)];
|
||||
|
||||
if (posts) {
|
||||
actions.push(setChannelRetryFailed(false));
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_REEFRESH_CHANNEL'));
|
||||
const posts = await retryGetPostsAction(getPosts(channelId), dispatch, getState);
|
||||
dispatch(setChannelRefreshing(false));
|
||||
return posts;
|
||||
};
|
||||
}
|
||||
@@ -526,8 +589,8 @@ export function setChannelDisplayName(displayName) {
|
||||
export function increasePostVisibility(channelId, postId) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {loadingPosts} = state.views.channel;
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const {loadingPosts, postVisibility} = state.views.channel;
|
||||
const currentPostVisibility = postVisibility[channelId] || 0;
|
||||
|
||||
if (loadingPosts[channelId]) {
|
||||
return true;
|
||||
@@ -538,8 +601,17 @@ export function increasePostVisibility(channelId, postId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPendingPost(postId, currentUserId)) {
|
||||
// This is the first created post in the channel
|
||||
// Check if we already have the posts that we want to show
|
||||
const loadedPostCount = state.views.channel.postCountInChannel[channelId] || 0;
|
||||
const desiredPostVisibility = currentPostVisibility + ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
|
||||
if (loadedPostCount >= desiredPostVisibility) {
|
||||
// We already have the posts, so we just need to show them
|
||||
dispatch(batchActions([
|
||||
doIncreasePostVisibility(channelId),
|
||||
setLoadMorePostsVisible(true),
|
||||
]));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -554,8 +626,7 @@ export function increasePostVisibility(channelId, postId) {
|
||||
|
||||
const pageSize = ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
|
||||
const postAction = getPostsBefore(channelId, postId, 0, pageSize);
|
||||
const result = await dispatch(fetchPostActionWithRetry(postAction));
|
||||
const result = await retryGetPostsAction(getPostsBefore(channelId, postId, 0, pageSize), dispatch, getState);
|
||||
|
||||
const actions = [{
|
||||
type: ViewTypes.LOADING_POSTS,
|
||||
@@ -563,19 +634,27 @@ export function increasePostVisibility(channelId, postId) {
|
||||
channelId,
|
||||
}];
|
||||
|
||||
if (result) {
|
||||
actions.push(setChannelRetryFailed(false));
|
||||
}
|
||||
|
||||
let hasMorePost = false;
|
||||
if (result?.order) {
|
||||
const count = result.order.length;
|
||||
hasMorePost = count >= pageSize;
|
||||
|
||||
actions.push({
|
||||
type: ViewTypes.INCREASE_POST_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
count,
|
||||
},
|
||||
});
|
||||
|
||||
// make sure to increment the posts visibility
|
||||
// only if we got results
|
||||
actions.push(doIncreasePostVisibility(channelId));
|
||||
|
||||
actions.push(setLoadMorePostsVisible(hasMorePost));
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_LOAD_MORE_POSTS'));
|
||||
dispatch(batchActions(actions));
|
||||
telemetry.end(['posts:loading']);
|
||||
telemetry.save();
|
||||
|
||||
@@ -583,6 +662,14 @@ export function increasePostVisibility(channelId, postId) {
|
||||
};
|
||||
}
|
||||
|
||||
function doIncreasePostVisibility(channelId) {
|
||||
return {
|
||||
type: ViewTypes.INCREASE_POST_VISIBILITY,
|
||||
data: channelId,
|
||||
amount: ViewTypes.POST_VISIBILITY_CHUNK_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function setLoadMorePostsVisible(visible) {
|
||||
return {
|
||||
type: ViewTypes.SET_LOAD_MORE_POSTS_VISIBLE,
|
||||
@@ -590,187 +677,26 @@ function setLoadMorePostsVisible(visible) {
|
||||
};
|
||||
}
|
||||
|
||||
function loadGroupData() {
|
||||
return async (dispatch, getState) => {
|
||||
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';
|
||||
|
||||
if (hasLicense && team && isMinimumServerVersion(serverVersion, 5, 24)) {
|
||||
for (let i = 0; i <= MAX_RETRIES; i++) {
|
||||
try {
|
||||
if (team.group_constrained) {
|
||||
const [getAllGroupsAssociatedToChannelsInTeam, getAllGroupsAssociatedToTeam] = await Promise.all([ //eslint-disable-line no-await-in-loop
|
||||
Client4.getAllGroupsAssociatedToChannelsInTeam(team.id, true),
|
||||
Client4.getAllGroupsAssociatedToTeam(team.id, true),
|
||||
]);
|
||||
|
||||
if (getAllGroupsAssociatedToChannelsInTeam.groups) {
|
||||
actions.push({
|
||||
type: GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNELS_IN_TEAM,
|
||||
data: {groupsByChannelId: getAllGroupsAssociatedToChannelsInTeam.groups},
|
||||
});
|
||||
}
|
||||
|
||||
if (getAllGroupsAssociatedToTeam) {
|
||||
actions.push({
|
||||
type: GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_TEAM,
|
||||
data: {...getAllGroupsAssociatedToTeam, teamID: team.id},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const [getAllGroupsAssociatedToChannelsInTeam, getGroups] = await Promise.all([ //eslint-disable-line no-await-in-loop
|
||||
Client4.getAllGroupsAssociatedToChannelsInTeam(team.id, true),
|
||||
Client4.getGroups(true, 0, 0),
|
||||
]);
|
||||
|
||||
if (getAllGroupsAssociatedToChannelsInTeam.groups) {
|
||||
actions.push({
|
||||
type: GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNELS_IN_TEAM,
|
||||
data: {groupsByChannelId: getAllGroupsAssociatedToChannelsInTeam.groups},
|
||||
});
|
||||
}
|
||||
|
||||
if (getGroups) {
|
||||
actions.push({
|
||||
type: GroupTypes.RECEIVED_GROUPS,
|
||||
data: getGroups,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
} catch (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) {
|
||||
dispatch(batchActions(actions, 'BATCH_GROUP_DATA'));
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
function setInitialPostVisibility(channelId) {
|
||||
return {
|
||||
type: ViewTypes.SET_INITIAL_POST_VISIBILITY,
|
||||
data: channelId,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadChannelsForTeam(teamId, skipDispatch = false) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const data = {
|
||||
sync: true,
|
||||
teamId,
|
||||
teamChannels: getChannelsIdForTeam(state, teamId),
|
||||
};
|
||||
|
||||
const actions = [];
|
||||
|
||||
if (currentUserId) {
|
||||
for (let i = 0; i <= MAX_RETRIES; i++) {
|
||||
try {
|
||||
console.log('Fetching channels attempt', teamId, (i + 1)); //eslint-disable-line no-console
|
||||
const [channels, channelMembers] = await Promise.all([ //eslint-disable-line no-await-in-loop
|
||||
Client4.getMyChannels(teamId, true),
|
||||
Client4.getMyChannelMembers(teamId),
|
||||
]);
|
||||
|
||||
data.channels = channels;
|
||||
data.channelMembers = channelMembers;
|
||||
break;
|
||||
} catch (err) {
|
||||
if (i === MAX_RETRIES) {
|
||||
const hasChannelsLoaded = state.entities.channels.channelsInTeam[teamId]?.size > 0;
|
||||
return {error: hasChannelsLoaded ? null : err};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.channels) {
|
||||
actions.push({
|
||||
type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS,
|
||||
data,
|
||||
});
|
||||
|
||||
if (!skipDispatch) {
|
||||
const rolesToLoad = new Set();
|
||||
const members = data.channelMembers;
|
||||
for (const member of members) {
|
||||
for (const role of member.roles.split(' ')) {
|
||||
rolesToLoad.add(role);
|
||||
}
|
||||
}
|
||||
|
||||
if (rolesToLoad.size > 0) {
|
||||
try {
|
||||
data.roles = await Client4.getRolesByNames(Array.from(rolesToLoad));
|
||||
if (data.roles.length) {
|
||||
actions.push({
|
||||
type: RoleTypes.RECEIVED_ROLES,
|
||||
data: data.roles,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
//eslint-disable-next-line no-console
|
||||
console.log('Could not retrieve channel members roles for the user');
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_LOAD_CHANNELS_FOR_TEAM'));
|
||||
}
|
||||
|
||||
// Fetch needed profiles from channel creators and direct channels
|
||||
dispatch(loadSidebar(data));
|
||||
|
||||
dispatch(loadUnreadChannelPosts(data.channels, data.channelMembers));
|
||||
}
|
||||
|
||||
dispatch(loadGroupData());
|
||||
}
|
||||
|
||||
return {data};
|
||||
function setLastChannelForTeam(teamId, channelId) {
|
||||
return {
|
||||
type: ViewTypes.SET_LAST_CHANNEL_FOR_TEAM,
|
||||
teamId,
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadSidebar(data) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {channels, channelMembers} = data;
|
||||
|
||||
const sidebarActions = await loadSidebarDirectMessagesProfiles(state, channels, channelMembers);
|
||||
if (sidebarActions.length) {
|
||||
dispatch(batchActions(sidebarActions, 'BATCH_LOAD_SIDEBAR'));
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function resetUnreadMessageCount(channelId) {
|
||||
return async (dispatch) => {
|
||||
dispatch({
|
||||
type: ChannelTypes.SET_UNREAD_MSG_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
count: 0,
|
||||
},
|
||||
});
|
||||
function selectChannelWithMember(channelId, channel, member) {
|
||||
return {
|
||||
type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER,
|
||||
data: channelId,
|
||||
channel,
|
||||
member,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,25 +4,24 @@
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import initialState from 'app/initial_state';
|
||||
import {ViewTypes} from 'app/constants';
|
||||
import testHelper from 'test/test_helper';
|
||||
|
||||
import * as ChannelActions from '@actions/views/channel';
|
||||
import {ViewTypes} from '@constants';
|
||||
import {ChannelTypes} from '@mm-redux/action_types';
|
||||
import postReducer from '@mm-redux/reducers/entities/posts';
|
||||
import initialState from '@store/initial_state';
|
||||
|
||||
import * as ChannelActions from 'app/actions/views/channel';
|
||||
const {
|
||||
handleSelectChannel,
|
||||
handleSelectChannelByName,
|
||||
loadPostsIfNecessaryWithRetry,
|
||||
} = ChannelActions;
|
||||
|
||||
import postReducer from 'mattermost-redux/reducers/entities/posts';
|
||||
|
||||
const MOCK_CHANNEL_MARK_AS_READ = 'MOCK_CHANNEL_MARK_AS_READ';
|
||||
const MOCK_CHANNEL_MARK_AS_VIEWED = 'MOCK_CHANNEL_MARK_AS_VIEWED';
|
||||
|
||||
jest.mock('@mm-redux/actions/channels', () => {
|
||||
const channelActions = jest.requireActual('../../mm-redux/actions/channels');
|
||||
jest.mock('mattermost-redux/actions/channels', () => {
|
||||
const channelActions = require.requireActual('mattermost-redux/actions/channels');
|
||||
return {
|
||||
...channelActions,
|
||||
markChannelAsRead: jest.fn().mockReturnValue({type: 'MOCK_CHANNEL_MARK_AS_READ'}),
|
||||
@@ -30,8 +29,8 @@ jest.mock('@mm-redux/actions/channels', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@mm-redux/selectors/entities/teams', () => {
|
||||
const teamSelectors = jest.requireActual('../../mm-redux/selectors/entities/teams');
|
||||
jest.mock('mattermost-redux/selectors/entities/teams', () => {
|
||||
const teamSelectors = require.requireActual('mattermost-redux/selectors/entities/teams');
|
||||
return {
|
||||
...teamSelectors,
|
||||
getTeamByName: jest.fn(() => ({name: 'current-team-name'})),
|
||||
@@ -49,7 +48,7 @@ describe('Actions.Views.Channel', () => {
|
||||
const MOCK_RECEIVED_POSTS_IN_CHANNEL = 'RECEIVED_POSTS_IN_CHANNEL';
|
||||
const MOCK_RECEIVED_POSTS_SINCE = 'MOCK_RECEIVED_POSTS_SINCE';
|
||||
|
||||
const actions = require('@mm-redux/actions/channels');
|
||||
const actions = require('mattermost-redux/actions/channels');
|
||||
actions.getChannelByNameAndTeamName = jest.fn((teamName) => {
|
||||
if (teamName) {
|
||||
return {
|
||||
@@ -67,7 +66,7 @@ describe('Actions.Views.Channel', () => {
|
||||
type: MOCK_SELECT_CHANNEL_TYPE,
|
||||
data: 'selected-channel-id',
|
||||
});
|
||||
const postActions = require('./post');
|
||||
const postActions = require('mattermost-redux/actions/posts');
|
||||
postActions.getPostsSince = jest.fn(() => {
|
||||
return {
|
||||
type: MOCK_RECEIVED_POSTS_SINCE,
|
||||
@@ -97,7 +96,7 @@ describe('Actions.Views.Channel', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const postUtils = require('@mm-redux/utils/post_utils');
|
||||
const postUtils = require('mattermost-redux/utils/post_utils');
|
||||
postUtils.getLastCreateAt = jest.fn((array) => {
|
||||
return array[0].create_at;
|
||||
});
|
||||
@@ -117,36 +116,25 @@ describe('Actions.Views.Channel', () => {
|
||||
},
|
||||
channels: {
|
||||
currentChannelId,
|
||||
manuallyUnread: {},
|
||||
channels: {
|
||||
'channel-id': {id: 'channel-id', display_name: 'Test Channel'},
|
||||
'channel-id-2': {id: 'channel-id-2', display_name: 'Test Channel'},
|
||||
},
|
||||
myMembers: {
|
||||
'channel-id': {channel_id: 'channel-id', user_id: currentUserId, mention_count: 0, msg_count: 0},
|
||||
'channel-id-2': {channel_id: 'channel-id-2', user_id: currentUserId, mention_count: 0, msg_count: 0},
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
currentTeamId,
|
||||
teams: {
|
||||
[currentTeamId]: {
|
||||
id: currentTeamId,
|
||||
name: currentTeamName,
|
||||
currentTeamId,
|
||||
currentTeams: {
|
||||
[currentTeamId]: {
|
||||
name: currentTeamName,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const channelSelectors = require('@mm-redux/selectors/entities/channels');
|
||||
const channelSelectors = require('mattermost-redux/selectors/entities/channels');
|
||||
channelSelectors.getChannel = jest.fn((state, channelId) => ({data: channelId}));
|
||||
channelSelectors.getCurrentChannelId = jest.fn(() => currentChannelId);
|
||||
channelSelectors.getMyChannelMember = jest.fn(() => ({data: {member: {}}}));
|
||||
|
||||
const appChannelSelectors = require('app/selectors/channel');
|
||||
appChannelSelectors.getChannelReachable = jest.fn(() => true);
|
||||
|
||||
test('handleSelectChannelByName success', async () => {
|
||||
store = mockStore(storeObj);
|
||||
|
||||
@@ -156,14 +144,15 @@ describe('Actions.Views.Channel', () => {
|
||||
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
|
||||
expect(receivedChannel).toBe(true);
|
||||
|
||||
const selectedChannel = storeActions.some(({type}) => type === MOCK_RECEIVE_CHANNEL_TYPE);
|
||||
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCHING_REDUCER.BATCH');
|
||||
const selectedChannel = storeBatchActions[0].payload.some((action) => action.type === MOCK_SELECT_CHANNEL_TYPE);
|
||||
expect(selectedChannel).toBe(true);
|
||||
});
|
||||
|
||||
test('handleSelectChannelByName failure from null currentTeamName', async () => {
|
||||
const failStoreObj = {...storeObj};
|
||||
failStoreObj.entities.teams.currentTeamId = 'not-in-current-teams';
|
||||
store = mockStore(failStoreObj);
|
||||
failStoreObj.entities.teams.teams.currentTeamId = 'not-in-current-teams';
|
||||
store = mockStore(storeObj);
|
||||
|
||||
await store.dispatch(handleSelectChannelByName(currentChannelName, null));
|
||||
|
||||
@@ -175,36 +164,6 @@ describe('Actions.Views.Channel', () => {
|
||||
expect(storeBatchActions).toBe(false);
|
||||
});
|
||||
|
||||
test('handleSelectChannelByName failure from no permission to channel', async () => {
|
||||
store = mockStore({...storeObj});
|
||||
actions.getChannelByNameAndTeamName = jest.fn(() => {
|
||||
return {
|
||||
type: 'MOCK_ERROR',
|
||||
error: {
|
||||
message: "Can't get to channel.",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await store.dispatch(handleSelectChannelByName(currentChannelName, currentTeamName));
|
||||
|
||||
const storeActions = store.getActions();
|
||||
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
|
||||
expect(receivedChannel).toBe(false);
|
||||
});
|
||||
|
||||
test('handleSelectChannelByName failure from unreachable channel', async () => {
|
||||
appChannelSelectors.getChannelReachable = jest.fn(() => false);
|
||||
|
||||
store = mockStore(storeObj);
|
||||
|
||||
await store.dispatch(handleSelectChannelByName(currentChannelName, currentTeamName));
|
||||
|
||||
const storeActions = store.getActions();
|
||||
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
|
||||
expect(receivedChannel).toBe(false);
|
||||
});
|
||||
|
||||
test('loadPostsIfNecessaryWithRetry for the first time', async () => {
|
||||
store = mockStore(storeObj);
|
||||
|
||||
@@ -212,9 +171,9 @@ describe('Actions.Views.Channel', () => {
|
||||
expect(postActions.getPosts).toBeCalled();
|
||||
|
||||
const storeActions = store.getActions();
|
||||
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCH_LOAD_POSTS_IN_CHANNEL');
|
||||
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCHING_REDUCER.BATCH');
|
||||
const receivedPosts = storeActions.find(({type}) => type === MOCK_RECEIVED_POSTS);
|
||||
const receivedPostsAtAction = storeBatchActions[0].payload.some((action) => action.type === ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME);
|
||||
const receivedPostsAtAction = storeBatchActions[0].payload.some((action) => action.type === 'RECEIVED_POSTS_FOR_CHANNEL_AT_TIME');
|
||||
|
||||
nextPostState = postReducer(store.getState().entities.posts, receivedPosts);
|
||||
nextPostState = postReducer(nextPostState, {
|
||||
@@ -286,45 +245,35 @@ describe('Actions.Views.Channel', () => {
|
||||
});
|
||||
|
||||
const handleSelectChannelCases = [
|
||||
[currentChannelId],
|
||||
[`${currentChannelId}-2`],
|
||||
[`not-${currentChannelId}`],
|
||||
[`not-${currentChannelId}-2`],
|
||||
[currentChannelId, true],
|
||||
[currentChannelId, false],
|
||||
[`not-${currentChannelId}`, true],
|
||||
[`not-${currentChannelId}`, false],
|
||||
];
|
||||
test.each(handleSelectChannelCases)('handleSelectChannel dispatches selectChannelWithMember', async (channelId) => {
|
||||
const testObj = {...storeObj};
|
||||
testObj.entities.teams.currentTeamId = currentTeamId;
|
||||
store = mockStore(testObj);
|
||||
test.each(handleSelectChannelCases)('handleSelectChannel dispatches selectChannelWithMember', async (channelId, fromPushNotification) => {
|
||||
store = mockStore({...storeObj});
|
||||
|
||||
await store.dispatch(handleSelectChannel(channelId));
|
||||
await store.dispatch(handleSelectChannel(channelId, fromPushNotification));
|
||||
const storeActions = store.getActions();
|
||||
const storeBatchActions = storeActions.find(({type}) => type === 'BATCH_SWITCH_CHANNEL');
|
||||
const selectChannelWithMember = storeBatchActions?.payload.find(({type}) => type === ChannelTypes.SELECT_CHANNEL);
|
||||
const storeBatchActions = storeActions.find(({type}) => type === 'BATCHING_REDUCER.BATCH');
|
||||
const selectChannelWithMember = storeBatchActions.payload.find(({type}) => type === ViewTypes.SELECT_CHANNEL_WITH_MEMBER);
|
||||
const viewedAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_VIEWED);
|
||||
const readAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_READ);
|
||||
|
||||
const expectedSelectChannelWithMember = {
|
||||
type: ChannelTypes.SELECT_CHANNEL,
|
||||
type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER,
|
||||
data: channelId,
|
||||
extra: {
|
||||
channel: {
|
||||
id: channelId,
|
||||
display_name: 'Test Channel',
|
||||
},
|
||||
member: {
|
||||
channel_id: channelId,
|
||||
user_id: currentUserId,
|
||||
mention_count: 0,
|
||||
msg_count: 0,
|
||||
},
|
||||
teamId: currentTeamId,
|
||||
channel: {
|
||||
data: channelId,
|
||||
},
|
||||
member: {
|
||||
data: {
|
||||
member: {},
|
||||
},
|
||||
},
|
||||
|
||||
};
|
||||
if (channelId.includes('not')) {
|
||||
expect(selectChannelWithMember).toBe(undefined);
|
||||
} else {
|
||||
expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember);
|
||||
}
|
||||
expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember);
|
||||
expect(viewedAction).not.toBe(null);
|
||||
expect(readAction).not.toBe(null);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {addChannelMember} from '@mm-redux/actions/channels';
|
||||
import {addChannelMember} from 'mattermost-redux/actions/channels';
|
||||
|
||||
export function handleAddChannelMembers(channelId, members) {
|
||||
return async (dispatch) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {removeChannelMember} from '@mm-redux/actions/channels';
|
||||
import {removeChannelMember} from 'mattermost-redux/actions/channels';
|
||||
|
||||
export function handleRemoveChannelMembers(channelId, members) {
|
||||
return async (dispatch, getState) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {IntegrationTypes} from '@mm-redux/action_types';
|
||||
import {executeCommand as executeCommandService} from '@mm-redux/actions/integrations';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {IntegrationTypes} from 'mattermost-redux/action_types';
|
||||
import {executeCommand as executeCommandService} from 'mattermost-redux/actions/integrations';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
export function executeCommand(message, channelId, rootId) {
|
||||
return async (dispatch, getState) => {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {handleSelectChannel, setChannelDisplayName} from './channel';
|
||||
import {createChannel} from '@mm-redux/actions/channels';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
|
||||
import {cleanUpUrlable} from '@mm-redux/utils/channel_utils';
|
||||
import {generateId} from '@mm-redux/utils/helpers';
|
||||
import {createChannel} from 'mattermost-redux/actions/channels';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
import {cleanUpUrlable} from 'mattermost-redux/utils/channel_utils';
|
||||
import {generateId} from 'mattermost-redux/utils/helpers';
|
||||
|
||||
export function generateChannelNameFromDisplayName(displayName) {
|
||||
let name = cleanUpUrlable(displayName);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {updateMe, setDefaultProfileImage} from '@mm-redux/actions/users';
|
||||
import {updateMe, setDefaultProfileImage} from 'mattermost-redux/actions/users';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import {EmojiTypes} from '@mm-redux/action_types';
|
||||
import {addReaction as serviceAddReaction, getNeededCustomEmojis} from '@mm-redux/actions/posts';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {getPostIdsInCurrentChannel, makeGetPostIdsForThread} from '@mm-redux/selectors/entities/posts';
|
||||
import {addReaction as serviceAddReaction} from 'mattermost-redux/actions/posts';
|
||||
import {getPostIdsInCurrentChannel, makeGetPostIdsForThread} from 'mattermost-redux/selectors/entities/posts';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
@@ -46,55 +42,3 @@ export function incrementEmojiPickerPage() {
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function getEmojisInPosts(posts) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
// Do not wait for this as they need to be loaded one by one
|
||||
const emojisToLoad = getNeededCustomEmojis(state, posts);
|
||||
|
||||
if (emojisToLoad?.size > 0) {
|
||||
const promises = Array.from(emojisToLoad).map((name) => getCustomEmojiByName(name));
|
||||
const result = await Promise.all(promises);
|
||||
const actions = [];
|
||||
const data = [];
|
||||
|
||||
result.forEach((emoji, index) => {
|
||||
const name = emojisToLoad[index];
|
||||
|
||||
if (emoji) {
|
||||
switch (emoji) {
|
||||
case 404:
|
||||
actions.push({type: EmojiTypes.CUSTOM_EMOJI_DOES_NOT_EXIST, data: name});
|
||||
break;
|
||||
default:
|
||||
data.push(emoji);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (data.length) {
|
||||
actions.push({type: EmojiTypes.RECEIVED_CUSTOM_EMOJIS, data});
|
||||
}
|
||||
|
||||
if (actions.length) {
|
||||
dispatch(batchActions(actions, 'BATCH_GET_EMOJIS_FOR_POSTS'));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getCustomEmojiByName(name) {
|
||||
try {
|
||||
const data = await Client4.getCustomEmojiByName(name);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.status_code === 404) {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {FileTypes} from '@mm-redux/action_types';
|
||||
import {FileTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
import {buildFileUploadData, generateId} from 'app/utils/file';
|
||||
|
||||
@@ -3,25 +3,41 @@
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import {getDataRetentionPolicy} from '@mm-redux/actions/general';
|
||||
import {GeneralTypes} from '@mm-redux/action_types';
|
||||
import {getSessions} from '@mm-redux/actions/users';
|
||||
import {autoUpdateTimezone} from '@mm-redux/actions/timezone';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {getConfig, getLicense} from '@mm-redux/selectors/entities/general';
|
||||
import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone';
|
||||
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
|
||||
import {getDataRetentionPolicy} from 'mattermost-redux/actions/general';
|
||||
import {GeneralTypes} from 'mattermost-redux/action_types';
|
||||
import {getSessions} from 'mattermost-redux/actions/users';
|
||||
import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {isTimezoneEnabled} from 'mattermost-redux/selectors/entities/timezone';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
import {setAppCredentials} from 'app/init/credentials';
|
||||
import PushNotifications from 'app/push_notifications';
|
||||
import {getDeviceTimezone} from 'app/utils/timezone';
|
||||
import {getDeviceTimezoneAsync} from 'app/utils/timezone';
|
||||
import {setCSRFFromCookie} from 'app/utils/security';
|
||||
import {loadConfigAndLicense} from 'app/actions/views/root';
|
||||
|
||||
export function handleLoginIdChanged(loginId) {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ViewTypes.LOGIN_ID_CHANGED,
|
||||
loginId,
|
||||
}, getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function handlePasswordChanged(password) {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ViewTypes.PASSWORD_CHANGED,
|
||||
password,
|
||||
}, getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function handleSuccessfulLogin() {
|
||||
return async (dispatch, getState) => {
|
||||
await dispatch(loadConfigAndLicense());
|
||||
|
||||
const state = getState();
|
||||
const config = getConfig(state);
|
||||
const license = getLicense(state);
|
||||
@@ -35,7 +51,7 @@ export function handleSuccessfulLogin() {
|
||||
|
||||
const enableTimezone = isTimezoneEnabled(state);
|
||||
if (enableTimezone) {
|
||||
const timezone = getDeviceTimezone();
|
||||
const timezone = await getDeviceTimezoneAsync();
|
||||
dispatch(autoUpdateTimezone(timezone));
|
||||
}
|
||||
|
||||
@@ -107,6 +123,8 @@ export function scheduleExpiredNotification(intl) {
|
||||
}
|
||||
|
||||
export default {
|
||||
handleLoginIdChanged,
|
||||
handlePasswordChanged,
|
||||
handleSuccessfulLogin,
|
||||
scheduleExpiredNotification,
|
||||
};
|
||||
|
||||
@@ -4,38 +4,60 @@
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
import {handleSuccessfulLogin} from 'app/actions/views/login';
|
||||
import {
|
||||
handleLoginIdChanged,
|
||||
handlePasswordChanged,
|
||||
} from 'app/actions/views/login';
|
||||
|
||||
jest.mock('app/init/credentials', () => ({
|
||||
setAppCredentials: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-native-cookies', () => ({
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
openURL: jest.fn(),
|
||||
canOpenURL: jest.fn(),
|
||||
getInitialURL: jest.fn(),
|
||||
get: () => Promise.resolve(({
|
||||
res: {
|
||||
MMCSRF: {
|
||||
value: 'the cookie',
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
describe('Actions.Views.Login', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'current-user-id',
|
||||
},
|
||||
general: {
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
store = mockStore({});
|
||||
});
|
||||
|
||||
test('handleSuccessfulLogin gets config and license ', async () => {
|
||||
const getClientConfig = jest.spyOn(Client4, 'getClientConfigOld');
|
||||
const getLicenseConfig = jest.spyOn(Client4, 'getClientLicenseOld');
|
||||
test('handleLoginIdChanged', () => {
|
||||
const loginId = 'email@example.com';
|
||||
|
||||
await store.dispatch(handleSuccessfulLogin());
|
||||
expect(getClientConfig).toHaveBeenCalled();
|
||||
expect(getLicenseConfig).toHaveBeenCalled();
|
||||
const action = {
|
||||
type: ViewTypes.LOGIN_ID_CHANGED,
|
||||
loginId,
|
||||
};
|
||||
store.dispatch(handleLoginIdChanged(loginId));
|
||||
expect(store.getActions()).toEqual([action]);
|
||||
});
|
||||
|
||||
test('handlePasswordChanged', () => {
|
||||
const password = 'password';
|
||||
const action = {
|
||||
type: ViewTypes.PASSWORD_CHANGED,
|
||||
password,
|
||||
};
|
||||
|
||||
store.dispatch(handlePasswordChanged(password));
|
||||
expect(store.getActions()).toEqual([action]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {getDirectChannelName} from '@mm-redux/utils/channel_utils';
|
||||
import {createDirectChannel, createGroupChannel} from '@mm-redux/actions/channels';
|
||||
import {getProfilesByIds, getStatusesByIds} from '@mm-redux/actions/users';
|
||||
import {getDirectChannelName} from 'mattermost-redux/utils/channel_utils';
|
||||
import {createDirectChannel, createGroupChannel} from 'mattermost-redux/actions/channels';
|
||||
import {getProfilesByIds, getStatusesByIds} from 'mattermost-redux/actions/users';
|
||||
import {handleSelectChannel, toggleDMChannel, toggleGMChannel} from 'app/actions/views/channel';
|
||||
|
||||
export function makeDirectChannel(otherUserId, switchToChannel = true) {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {intlShape} from 'react-intl';
|
||||
import {Keyboard} from 'react-native';
|
||||
|
||||
import {showModalOverCurrentContext} from '@actions/navigation';
|
||||
import {loadChannelsByTeamName} from '@actions/views/channel';
|
||||
import {selectFocusedPostId} from '@mm-redux/actions/posts';
|
||||
import type {DispatchFunc} from '@mm-redux/types/actions';
|
||||
import {permalinkBadTeam} from '@utils/general';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
export let showingPermalink = false;
|
||||
|
||||
export function showPermalink(intl: typeof intlShape, teamName: string, postId: string, openAsPermalink = true) {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
const loadTeam = await dispatch(loadChannelsByTeamName(teamName, permalinkBadTeam.bind(null, intl)));
|
||||
|
||||
if (!loadTeam.error) {
|
||||
Keyboard.dismiss();
|
||||
dispatch(selectFocusedPostId(postId));
|
||||
|
||||
if (!showingPermalink) {
|
||||
const screen = 'Permalink';
|
||||
const passProps = {
|
||||
isPermalink: openAsPermalink,
|
||||
onClose: () => {
|
||||
dispatch(closePermalink());
|
||||
},
|
||||
};
|
||||
|
||||
const options = {
|
||||
layout: {
|
||||
componentBackgroundColor: changeOpacity('#000', 0.2),
|
||||
},
|
||||
};
|
||||
|
||||
showingPermalink = true;
|
||||
showModalOverCurrentContext(screen, passProps, options);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function closePermalink() {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
showingPermalink = false;
|
||||
return dispatch(selectFocusedPostId(''));
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
import {Posts} from 'mattermost-redux/constants';
|
||||
import {doPostAction, receivedNewPost} from 'mattermost-redux/actions/posts';
|
||||
|
||||
import {UserTypes} from '@mm-redux/action_types';
|
||||
import {
|
||||
doPostAction,
|
||||
getNeededAtMentionedUsernames,
|
||||
receivedNewPost,
|
||||
receivedPost,
|
||||
receivedPosts,
|
||||
receivedPostsBefore,
|
||||
receivedPostsInChannel,
|
||||
receivedPostsSince,
|
||||
receivedPostsInThread,
|
||||
} from '@mm-redux/actions/posts';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {Posts} from '@mm-redux/constants';
|
||||
import {getPost as selectPost, getPostIdsInChannel} from '@mm-redux/selectors/entities/posts';
|
||||
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
|
||||
import {removeUserFromList} from '@mm-redux/utils/user_utils';
|
||||
import {isUnreadChannel, isArchivedChannel} from '@mm-redux/utils/channel_utils';
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
import {ViewTypes} from '@constants';
|
||||
import {generateId} from '@utils/file';
|
||||
import {getChannelSinceValue} from '@utils/channels';
|
||||
|
||||
import {getEmojisInPosts} from './emoji';
|
||||
import {generateId} from 'app/utils/file';
|
||||
|
||||
export function sendAddToChannelEphemeralPost(user, addedUsername, message, channelId, postRootId = '') {
|
||||
return async (dispatch) => {
|
||||
@@ -78,406 +58,3 @@ export function selectAttachmentMenuAction(postId, actionId, text, value) {
|
||||
dispatch(doPostAction(postId, actionId, value));
|
||||
};
|
||||
}
|
||||
|
||||
export function getPosts(channelId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const state = getState();
|
||||
const {postsInChannel} = state.entities.posts;
|
||||
const postForChannel = postsInChannel[channelId];
|
||||
const data = await Client4.getPosts(channelId, page, perPage);
|
||||
const posts = Object.values(data.posts);
|
||||
const actions = [{
|
||||
type: ViewTypes.SET_CHANNEL_RETRY_FAILED,
|
||||
failed: false,
|
||||
}];
|
||||
|
||||
if (posts?.length) {
|
||||
actions.push(receivedPosts(data));
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
}
|
||||
|
||||
if (posts?.length || !postForChannel) {
|
||||
actions.push(receivedPostsInChannel(data, channelId, page === 0, data.prev_post_id === ''));
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POSTS'));
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getPost(postId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await Client4.getPost(postId);
|
||||
|
||||
if (data) {
|
||||
const actions = [
|
||||
receivedPost(data),
|
||||
];
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch([data]));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POST'));
|
||||
}
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostsSince(channelId, since) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await Client4.getPostsSince(channelId, since);
|
||||
const posts = Object.values(data.posts);
|
||||
|
||||
if (posts?.length) {
|
||||
const actions = [
|
||||
receivedPosts(data),
|
||||
receivedPostsSince(data, channelId),
|
||||
];
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POSTS_SINCE'));
|
||||
}
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostsBefore(channelId, postId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await Client4.getPostsBefore(channelId, postId, page, perPage);
|
||||
const posts = Object.values(data.posts);
|
||||
|
||||
if (posts?.length) {
|
||||
const actions = [
|
||||
receivedPosts(data),
|
||||
receivedPostsBefore(data, channelId, postId, data.prev_post_id === ''),
|
||||
];
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POSTS_BEFORE'));
|
||||
}
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostThread(rootId, skipDispatch = false) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await Client4.getPostThread(rootId);
|
||||
const posts = Object.values(data.posts);
|
||||
|
||||
if (posts.length) {
|
||||
const actions = [
|
||||
receivedPosts(data),
|
||||
receivedPostsInThread(data, rootId),
|
||||
];
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
if (skipDispatch) {
|
||||
return {data: actions};
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POSTS_THREAD'));
|
||||
}
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostsAround(channelId, postId, perPage = Posts.POST_CHUNK_SIZE / 2) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const [before, thread, after] = await Promise.all([
|
||||
Client4.getPostsBefore(channelId, postId, 0, perPage),
|
||||
Client4.getPostThread(postId),
|
||||
Client4.getPostsAfter(channelId, postId, 0, perPage),
|
||||
]);
|
||||
|
||||
const data = {
|
||||
posts: {
|
||||
...after.posts,
|
||||
...thread.posts,
|
||||
...before.posts,
|
||||
},
|
||||
order: [ // Remember that the order is newest posts first
|
||||
...after.order,
|
||||
postId,
|
||||
...before.order,
|
||||
],
|
||||
next_post_id: after.next_post_id,
|
||||
prev_post_id: before.prev_post_id,
|
||||
};
|
||||
|
||||
const posts = Object.values(data.posts);
|
||||
|
||||
if (posts?.length) {
|
||||
const actions = [
|
||||
receivedPosts(data),
|
||||
receivedPostsInChannel(data, channelId, after.next_post_id === '', before.prev_post_id === ''),
|
||||
];
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POSTS_AROUND'));
|
||||
}
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function handleNewPostBatch(WebSocketMessage) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const post = JSON.parse(WebSocketMessage.data.post);
|
||||
const actions = [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 = await dispatch(getPostThread(post.root_id, true));
|
||||
if (thread.actions?.length) {
|
||||
actions.push(...thread.actions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch([post]));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostsAdditionalDataBatch(posts = []) {
|
||||
return async (dispatch, getState) => {
|
||||
const data = [];
|
||||
|
||||
if (!posts.length) {
|
||||
return {data};
|
||||
}
|
||||
|
||||
// Custom Emojis used in the posts
|
||||
// Do not wait for this as they need to be loaded one by one
|
||||
dispatch(getEmojisInPosts(posts));
|
||||
|
||||
try {
|
||||
const state = getState();
|
||||
const promises = [];
|
||||
const promiseTrace = [];
|
||||
const extra = userMetadataToLoadFromPosts(state, posts);
|
||||
|
||||
if (extra?.userIds.length) {
|
||||
promises.push(Client4.getProfilesByIds(extra.userIds));
|
||||
promiseTrace.push('ids');
|
||||
}
|
||||
|
||||
if (extra?.usernames.length) {
|
||||
promises.push(Client4.getProfilesByUsernames(extra.usernames));
|
||||
promiseTrace.push('usernames');
|
||||
}
|
||||
|
||||
if (extra?.statuses.length) {
|
||||
promises.push(Client4.getStatusesByIds(extra.statuses));
|
||||
promiseTrace.push('statuses');
|
||||
}
|
||||
|
||||
if (promises.length) {
|
||||
const result = await Promise.all(promises);
|
||||
result.forEach((p, index) => {
|
||||
if (p.length) {
|
||||
const type = promiseTrace[index];
|
||||
switch (type) {
|
||||
case 'statuses':
|
||||
data.push({
|
||||
type: UserTypes.RECEIVED_STATUSES,
|
||||
data: p,
|
||||
});
|
||||
break;
|
||||
default: {
|
||||
const {currentUserId} = state.entities.users;
|
||||
|
||||
removeUserFromList(currentUserId, p);
|
||||
data.push({
|
||||
type: UserTypes.RECEIVED_PROFILES_LIST,
|
||||
data: p,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return {data};
|
||||
};
|
||||
}
|
||||
|
||||
function userMetadataToLoadFromPosts(state, posts = []) {
|
||||
const {currentUserId, profiles, statuses} = state.entities.users;
|
||||
|
||||
// Profiles of users mentioned in the posts
|
||||
const usernamesToLoad = getNeededAtMentionedUsernames(state, posts);
|
||||
|
||||
// Statuses and profiles of the users who made the posts
|
||||
const userIdsToLoad = new Set();
|
||||
const statusesToLoad = new Set();
|
||||
|
||||
posts.forEach((post) => {
|
||||
const userId = post.user_id;
|
||||
|
||||
if (!statuses[userId]) {
|
||||
statusesToLoad.add(userId);
|
||||
}
|
||||
|
||||
if (userId === currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!profiles[userId]) {
|
||||
userIdsToLoad.add(userId);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
usernames: Array.from(usernamesToLoad),
|
||||
userIds: Array.from(userIdsToLoad),
|
||||
statuses: Array.from(statusesToLoad),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUnreadChannelPosts(channels, channelMembers) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
|
||||
const promises = [];
|
||||
const promiseTrace = [];
|
||||
|
||||
const channelMembersByChannel = {};
|
||||
channelMembers.forEach((member) => {
|
||||
channelMembersByChannel[member.channel_id] = member;
|
||||
});
|
||||
|
||||
channels.forEach((channel) => {
|
||||
if (channel.id === currentChannelId || isArchivedChannel(channel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isUnread = isUnreadChannel(channelMembersByChannel, channel);
|
||||
if (!isUnread) {
|
||||
return;
|
||||
}
|
||||
|
||||
const postIds = getPostIdsInChannel(state, channel.id);
|
||||
|
||||
let promise;
|
||||
const trace = {
|
||||
channelId: channel.id,
|
||||
since: false,
|
||||
};
|
||||
if (!postIds || !postIds.length) {
|
||||
// Get the first page of posts if it appears we haven't gotten it yet, like the webapp
|
||||
promise = Client4.getPosts(channel.id);
|
||||
} else {
|
||||
const since = getChannelSinceValue(state, channel.id, postIds);
|
||||
promise = Client4.getPostsSince(channel.id, since);
|
||||
trace.since = since;
|
||||
}
|
||||
|
||||
promises.push(promise);
|
||||
promiseTrace.push(trace);
|
||||
});
|
||||
|
||||
let posts = [];
|
||||
const actions = [];
|
||||
if (promises.length) {
|
||||
const results = await Promise.all(promises);
|
||||
results.forEach((data, index) => {
|
||||
const channelPosts = Object.values(data.posts);
|
||||
if (channelPosts.length) {
|
||||
posts = posts.concat(channelPosts);
|
||||
|
||||
const trace = promiseTrace[index];
|
||||
if (trace.since) {
|
||||
actions.push(receivedPostsSince(data, trace.channelId));
|
||||
} else {
|
||||
actions.push(receivedPostsInChannel(data, trace.channelId, true, data.prev_post_id === ''));
|
||||
}
|
||||
|
||||
actions.push({
|
||||
type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
|
||||
channelId: trace.channelId,
|
||||
time: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Fetched ${posts.length} posts from ${promises.length} unread channels`); //eslint-disable-line no-console
|
||||
if (posts.length) {
|
||||
// receivedPosts should be the first action dispatched as
|
||||
// receivedPostsSince and receivedPostsInChannel reducers are
|
||||
// dependent on it.
|
||||
actions.unshift(receivedPosts({posts}));
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable no-import-assign */
|
||||
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {PostTypes, UserTypes} from '@mm-redux/action_types';
|
||||
|
||||
import * as PostSelectors from '@mm-redux/selectors/entities/posts';
|
||||
import * as ChannelUtils from '@mm-redux/utils/channel_utils';
|
||||
|
||||
import {ViewTypes} from '@constants';
|
||||
import initialState from '@store/initial_state';
|
||||
|
||||
import {loadUnreadChannelPosts} from '@actions/views/post';
|
||||
|
||||
describe('Actions.Views.Post', () => {
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
let store;
|
||||
const currentChannelId = 'current-channel-id';
|
||||
const storeObj = {
|
||||
...initialState,
|
||||
entities: {
|
||||
...initialState.entities,
|
||||
channels: {
|
||||
...initialState.entities.channels,
|
||||
currentChannelId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const channels = [
|
||||
{id: 'channel-1'},
|
||||
{id: 'channel-2'},
|
||||
{id: 'channel-3'},
|
||||
];
|
||||
const channelMembers = [];
|
||||
|
||||
beforeEach(() => {
|
||||
ChannelUtils.isUnreadChannel = jest.fn().mockReturnValue(true);
|
||||
ChannelUtils.isArchivedChannel = jest.fn().mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts does not dispatch actions if no unread channels', async () => {
|
||||
ChannelUtils.isUnreadChannel = jest.fn().mockReturnValue(false);
|
||||
|
||||
store = mockStore(storeObj);
|
||||
await store.dispatch(loadUnreadChannelPosts(channels, channelMembers));
|
||||
|
||||
const storeActions = store.getActions();
|
||||
expect(storeActions).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts does not dispatch actions for archived channels', async () => {
|
||||
ChannelUtils.isArchivedChannel = jest.fn().mockReturnValue(true);
|
||||
Client4.getPosts = jest.fn().mockResolvedValue({posts: ['post-1', 'post-2']});
|
||||
|
||||
store = mockStore(storeObj);
|
||||
await store.dispatch(loadUnreadChannelPosts(channels, channelMembers));
|
||||
|
||||
const storeActions = store.getActions();
|
||||
expect(storeActions).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts does not dispatch actions for current channel', async () => {
|
||||
Client4.getPosts = jest.fn().mockResolvedValue({posts: ['post-1', 'post-2']});
|
||||
|
||||
store = mockStore(storeObj);
|
||||
await store.dispatch(loadUnreadChannelPosts([{id: currentChannelId}], channelMembers));
|
||||
|
||||
const storeActions = store.getActions();
|
||||
expect(storeActions).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts dispatches actions for unread channels with no postIds in channel', async () => {
|
||||
Client4.getPosts = jest.fn().mockResolvedValue({posts: ['post-1', 'post-2']});
|
||||
|
||||
store = mockStore(storeObj);
|
||||
await store.dispatch(loadUnreadChannelPosts(channels, channelMembers));
|
||||
|
||||
const actionTypes = store.getActions()[0].payload.map((action) => action.type);
|
||||
|
||||
// Actions dispatched:
|
||||
// RECEIVED_POSTS once and first, with all channel posts combined.
|
||||
// RECEIVED_POSTS_IN_CHANNEL and RECEIVED_POSTS_FOR_CHANNEL_AT_TIME for each channel.
|
||||
expect(actionTypes.length).toBe((2 * channels.length) + 1);
|
||||
expect(actionTypes[0]).toEqual(PostTypes.RECEIVED_POSTS);
|
||||
|
||||
const receivedPostsInChannelActions = actionTypes.filter((type) => type === PostTypes.RECEIVED_POSTS_IN_CHANNEL);
|
||||
expect(receivedPostsInChannelActions.length).toBe(channels.length);
|
||||
|
||||
const receivedPostsForChannelAtTimeActions = actionTypes.filter((type) => type === ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME);
|
||||
expect(receivedPostsForChannelAtTimeActions.length).toBe(channels.length);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts dispatches actions for unread channels with postIds in channel', async () => {
|
||||
PostSelectors.getPostIdsInChannel = jest.fn().mockReturnValue(['post-id-in-channel']);
|
||||
Client4.getPostsSince = jest.fn().mockResolvedValue({posts: ['post-1', 'post-2']});
|
||||
|
||||
const lastGetPosts = {};
|
||||
channels.forEach((channel) => {
|
||||
lastGetPosts[channel.id] = Date.now();
|
||||
});
|
||||
const lastConnectAt = Date.now() + 1000;
|
||||
store = mockStore({
|
||||
...storeObj,
|
||||
views: {
|
||||
channel: {
|
||||
lastGetPosts,
|
||||
},
|
||||
},
|
||||
websocket: {
|
||||
lastConnectAt,
|
||||
},
|
||||
});
|
||||
await store.dispatch(loadUnreadChannelPosts(channels, channelMembers));
|
||||
|
||||
const actionTypes = store.getActions()[0].payload.map((action) => action.type);
|
||||
|
||||
// Actions dispatched:
|
||||
// RECEIVED_POSTS once and first, with all channel posts combined.
|
||||
// RECEIVED_POSTS_SINCE and RECEIVED_POSTS_FOR_CHANNEL_AT_TIME for each channel.
|
||||
expect(actionTypes.length).toBe((2 * channels.length) + 1);
|
||||
expect(actionTypes[0]).toEqual(PostTypes.RECEIVED_POSTS);
|
||||
|
||||
const receivedPostsInChannelActions = actionTypes.filter((type) => type === PostTypes.RECEIVED_POSTS_SINCE);
|
||||
expect(receivedPostsInChannelActions.length).toBe(channels.length);
|
||||
|
||||
const receivedPostsForChannelAtTimeActions = actionTypes.filter((type) => type === ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME);
|
||||
expect(receivedPostsForChannelAtTimeActions.length).toBe(channels.length);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts dispatches additional actions for unread channels', async () => {
|
||||
const posts = [{
|
||||
user_id: 'user-id',
|
||||
message: '@user post-1',
|
||||
}];
|
||||
PostSelectors.getPostIdsInChannel = jest.fn().mockReturnValue(['post-id-in-channel']);
|
||||
Client4.getPostsSince = jest.fn().mockResolvedValue({posts});
|
||||
Client4.getProfilesByIds = jest.fn().mockResolvedValue(['data']);
|
||||
Client4.getProfilesByUsernames = jest.fn().mockResolvedValue(['data']);
|
||||
Client4.getStatusesByIds = jest.fn().mockResolvedValue(['data']);
|
||||
|
||||
const lastGetPosts = {};
|
||||
channels.forEach((channel) => {
|
||||
lastGetPosts[channel.id] = Date.now();
|
||||
});
|
||||
const lastConnectAt = Date.now() + 1000;
|
||||
store = mockStore({
|
||||
...storeObj,
|
||||
views: {
|
||||
channel: {
|
||||
lastGetPosts,
|
||||
},
|
||||
},
|
||||
websocket: {
|
||||
lastConnectAt,
|
||||
},
|
||||
});
|
||||
await store.dispatch(loadUnreadChannelPosts(channels, channelMembers));
|
||||
|
||||
const actionTypes = store.getActions()[0].payload.map((action) => action.type);
|
||||
|
||||
// Actions dispatched:
|
||||
// RECEIVED_POSTS once and first, with all channel posts combined.
|
||||
// RECEIVED_POSTS_SINCE and RECEIVED_POSTS_FOR_CHANNEL_AT_TIME for each channel.
|
||||
// RECEIVED_PROFILES_LIST twice, once for getProfilesByIds and once for getProfilesByUsernames
|
||||
// RECEIVED_STATUSES for getStatusesByIds
|
||||
expect(actionTypes.length).toBe((2 * channels.length) + 4);
|
||||
expect(actionTypes[0]).toEqual(PostTypes.RECEIVED_POSTS);
|
||||
|
||||
const receivedPostsInChannelActions = actionTypes.filter((type) => type === PostTypes.RECEIVED_POSTS_SINCE);
|
||||
expect(receivedPostsInChannelActions.length).toBe(channels.length);
|
||||
|
||||
const receivedPostsForChannelAtTimeActions = actionTypes.filter((type) => type === ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME);
|
||||
expect(receivedPostsForChannelAtTimeActions.length).toBe(channels.length);
|
||||
|
||||
const receivedProfiles = actionTypes.filter((type) => type === UserTypes.RECEIVED_PROFILES_LIST);
|
||||
expect(receivedProfiles.length).toBe(2);
|
||||
|
||||
const receivedStatuses = actionTypes.filter((type) => type === UserTypes.RECEIVED_STATUSES);
|
||||
expect(receivedStatuses.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,18 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
import {GeneralTypes} from 'mattermost-redux/action_types';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
import {fetchMyChannelsAndMembers} from 'mattermost-redux/actions/channels';
|
||||
import {getClientConfig, getDataRetentionPolicy, getLicenseConfig} from 'mattermost-redux/actions/general';
|
||||
import {receivedNewPost} from 'mattermost-redux/actions/posts';
|
||||
import {getMyTeams, getMyTeamMembers, selectTeam} from 'mattermost-redux/actions/teams';
|
||||
|
||||
import {NavigationTypes, ViewTypes} from '@constants';
|
||||
import {analytics} from '@init/analytics.ts';
|
||||
import {ChannelTypes, GeneralTypes, TeamTypes} from '@mm-redux/action_types';
|
||||
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';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {General} from '@mm-redux/constants';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import initialState from '@store/initial_state';
|
||||
import {getStateForReset} from '@store/utils';
|
||||
import {ViewTypes} from 'app/constants';
|
||||
import {recordTime} from 'app/utils/segment';
|
||||
|
||||
import {markAsViewedAndReadBatch} from './channel';
|
||||
import {handleSelectChannel} from 'app/actions/views/channel';
|
||||
|
||||
export function startDataCleanup() {
|
||||
return async (dispatch, getState) => {
|
||||
@@ -30,36 +26,24 @@ export function startDataCleanup() {
|
||||
export function loadConfigAndLicense() {
|
||||
return async (dispatch, getState) => {
|
||||
const {currentUserId} = getState().entities.users;
|
||||
const [configData, licenseData] = await Promise.all([
|
||||
getClientConfig()(dispatch, getState),
|
||||
getLicenseConfig()(dispatch, getState),
|
||||
]);
|
||||
|
||||
try {
|
||||
const [config, license] = await Promise.all([
|
||||
Client4.getClientConfigOld(),
|
||||
Client4.getClientLicenseOld(),
|
||||
]);
|
||||
const config = configData.data || {};
|
||||
const license = licenseData.data || {};
|
||||
|
||||
const actions = [{
|
||||
type: GeneralTypes.CLIENT_CONFIG_RECEIVED,
|
||||
data: config,
|
||||
}, {
|
||||
type: GeneralTypes.CLIENT_LICENSE_RECEIVED,
|
||||
data: license,
|
||||
}];
|
||||
|
||||
if (currentUserId) {
|
||||
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
|
||||
license.IsLicensed === 'true' && license.DataRetention === 'true') {
|
||||
dispatch(getDataRetentionPolicy());
|
||||
} else {
|
||||
actions.push({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
|
||||
}
|
||||
if (currentUserId) {
|
||||
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
|
||||
license.IsLicensed === 'true' && license.DataRetention === 'true') {
|
||||
getDataRetentionPolicy()(dispatch, getState);
|
||||
} else {
|
||||
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_LOAD_CONFIG_AND_LICENSE'));
|
||||
|
||||
return {config, license};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
return {config, license};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,62 +79,20 @@ export function loadFromPushNotification(notification) {
|
||||
await Promise.all(loading);
|
||||
}
|
||||
|
||||
dispatch(handleSelectTeamAndChannel(teamId, channelId));
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
const channel = channels[channelId];
|
||||
const member = myMembers[channelId];
|
||||
const actions = markAsViewedAndReadBatch(state, channelId);
|
||||
|
||||
// when the notification is from a team other than the current team
|
||||
if (teamId !== currentTeamId) {
|
||||
actions.push({type: TeamTypes.SELECT_TEAM, data: teamId});
|
||||
dispatch(selectTeam({id: teamId}));
|
||||
}
|
||||
|
||||
if (channel && currentChannelId !== channelId) {
|
||||
actions.push({
|
||||
type: ChannelTypes.SELECT_CHANNEL,
|
||||
data: channelId,
|
||||
extra: {
|
||||
channel,
|
||||
member,
|
||||
teamId: channel.team_id || currentTeamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (actions.length) {
|
||||
dispatch(batchActions(actions, 'BATCH_SELECT_TEAM_AND_CHANNEL'));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('channel switch from push notification to', channel?.display_name || channel?.id, (Date.now() - dt), 'ms');
|
||||
dispatch(handleSelectChannel(channelId, true));
|
||||
};
|
||||
}
|
||||
|
||||
export function purgeOfflineStore() {
|
||||
return (dispatch, getState) => {
|
||||
const currentState = getState();
|
||||
|
||||
dispatch({
|
||||
type: General.OFFLINE_STORE_PURGE,
|
||||
data: getStateForReset(initialState, currentState),
|
||||
});
|
||||
|
||||
EventEmitter.emit(NavigationTypes.RESTART_APP);
|
||||
};
|
||||
return {type: General.OFFLINE_STORE_PURGE};
|
||||
}
|
||||
|
||||
// A non-optimistic version of the createPost action in app/mm-redux with the file handling
|
||||
// A non-optimistic version of the createPost action in mattermost-redux with the file handling
|
||||
// removed since it's not needed.
|
||||
export function createPostForNotificationReply(post) {
|
||||
return async (dispatch, getState) => {
|
||||
@@ -182,7 +124,7 @@ export function recordLoadTime(screenName, category) {
|
||||
return async (dispatch, getState) => {
|
||||
const {currentUserId} = getState().entities.users;
|
||||
|
||||
analytics.recordTime(screenName, category, currentUserId);
|
||||
recordTime(screenName, category, currentUserId);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
export function handleSearchDraftChanged(text) {
|
||||
return {
|
||||
type: ViewTypes.SEARCH_DRAFT_CHANGED,
|
||||
text,
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ViewTypes.SEARCH_DRAFT_CHANGED,
|
||||
text,
|
||||
}, getState);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
import {GeneralTypes} from '@mm-redux/action_types';
|
||||
import {GeneralTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
export function handleServerUrlChanged(serverUrl) {
|
||||
return batchActions([
|
||||
{type: GeneralTypes.CLIENT_CONFIG_RESET},
|
||||
{type: GeneralTypes.CLIENT_LICENSE_RESET},
|
||||
{type: ViewTypes.SERVER_URL_CHANGED, serverUrl},
|
||||
], 'BATCH_SERVER_URL_CHANGED');
|
||||
return async (dispatch, getState) => {
|
||||
dispatch(batchActions([
|
||||
{type: GeneralTypes.CLIENT_CONFIG_RESET},
|
||||
{type: GeneralTypes.CLIENT_LICENSE_RESET},
|
||||
{type: ViewTypes.SERVER_URL_CHANGED, serverUrl},
|
||||
]), getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function setServerUrl(serverUrl) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {batchActions} from 'redux-batched-actions';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import {GeneralTypes} from '@mm-redux/action_types';
|
||||
import {GeneralTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
@@ -20,13 +20,13 @@ describe('Actions.Views.SelectServer', () => {
|
||||
store = mockStore({});
|
||||
});
|
||||
|
||||
test('handleServerUrlChanged', () => {
|
||||
test('handleServerUrlChanged', async () => {
|
||||
const serverUrl = 'https://mattermost.example.com';
|
||||
const actions = batchActions([
|
||||
{type: GeneralTypes.CLIENT_CONFIG_RESET},
|
||||
{type: GeneralTypes.CLIENT_LICENSE_RESET},
|
||||
{type: ViewTypes.SERVER_URL_CHANGED, serverUrl},
|
||||
], 'BATCH_SERVER_URL_CHANGED');
|
||||
]);
|
||||
|
||||
store.dispatch(handleServerUrlChanged(serverUrl));
|
||||
expect(store.getActions()).toEqual([actions]);
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
import {TeamTypes} from 'mattermost-redux/action_types';
|
||||
import {getMyTeams} from 'mattermost-redux/actions/teams';
|
||||
import {RequestStatus} from 'mattermost-redux/constants';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import EventEmitter from 'mattermost-redux/utils/event_emitter';
|
||||
|
||||
import {lastChannelIdForTeam} from '@actions/helpers/channels';
|
||||
import {NavigationTypes} from '@constants';
|
||||
import {ChannelTypes, TeamTypes} from '@mm-redux/action_types';
|
||||
import {getMyTeams} from '@mm-redux/actions/teams';
|
||||
import {RequestStatus} from '@mm-redux/constants';
|
||||
import {getConfig} from '@mm-redux/selectors/entities/general';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {selectFirstAvailableTeam} from '@utils/teams';
|
||||
import {NavigationTypes} from 'app/constants';
|
||||
import {selectFirstAvailableTeam} from 'app/utils/teams';
|
||||
|
||||
export function handleTeamChange(teamId) {
|
||||
return async (dispatch, getState) => {
|
||||
@@ -20,28 +18,7 @@ export function handleTeamChange(teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = [{type: TeamTypes.SELECT_TEAM, data: teamId}];
|
||||
const {channels, myMembers} = state.entities.channels;
|
||||
const channelId = lastChannelIdForTeam(state, teamId);
|
||||
|
||||
if (channelId) {
|
||||
const channel = channels[channelId];
|
||||
const member = myMembers[channelId];
|
||||
|
||||
actions.push({
|
||||
type: ChannelTypes.SELECT_CHANNEL,
|
||||
data: channelId,
|
||||
extra: {
|
||||
channel,
|
||||
member,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
actions.push({type: ChannelTypes.SELECT_CHANNEL, data: '', extra: {}});
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_SWITCH_TEAM'));
|
||||
dispatch({type: TeamTypes.SELECT_TEAM, data: teamId});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,16 +27,10 @@ export function selectDefaultTeam() {
|
||||
const state = getState();
|
||||
|
||||
const {ExperimentalPrimaryTeam} = getConfig(state);
|
||||
const {teams, myMembers} = state.entities.teams;
|
||||
const myTeams = Object.keys(teams).reduce((result, id) => {
|
||||
if (myMembers[id]) {
|
||||
result.push(teams[id]);
|
||||
}
|
||||
const {teams: allTeams, myMembers} = state.entities.teams;
|
||||
const teams = Object.keys(myMembers).map((key) => allTeams[key]);
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
let defaultTeam = selectFirstAvailableTeam(myTeams, ExperimentalPrimaryTeam);
|
||||
let defaultTeam = selectFirstAvailableTeam(teams, ExperimentalPrimaryTeam);
|
||||
|
||||
if (defaultTeam) {
|
||||
dispatch(handleTeamChange(defaultTeam.id));
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import {getSessions} from '@mm-redux/actions/users';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {getConfig} from '@mm-redux/selectors/entities/general';
|
||||
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
|
||||
|
||||
import PushNotifications from 'app/push_notifications';
|
||||
|
||||
const sortByNewest = (a, b) => {
|
||||
if (a.create_at > b.create_at) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
};
|
||||
|
||||
export function scheduleExpiredNotification(intl) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {currentUserId} = state.entities.users;
|
||||
const config = getConfig(state);
|
||||
|
||||
if (isMinimumServerVersion(Client4.serverVersion, 5, 24) && config.ExtendSessionLengthWithActivity === 'true') {
|
||||
PushNotifications.cancelAllLocalNotifications();
|
||||
return;
|
||||
}
|
||||
|
||||
let sessions;
|
||||
try {
|
||||
sessions = await dispatch(getSessions(currentUserId));
|
||||
} catch (e) {
|
||||
console.warn('Failed to get current session', e); // eslint-disable-line no-console
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(sessions.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.data.sort(sortByNewest)[0];
|
||||
const expiresAt = session?.expires_at || 0; //eslint-disable-line camelcase
|
||||
const expiresInDays = parseInt(Math.ceil(Math.abs(moment.duration(moment().diff(expiresAt)).asDays())), 10);
|
||||
|
||||
const message = intl.formatMessage({
|
||||
id: 'mobile.session_expired',
|
||||
defaultMessage: 'Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.',
|
||||
}, {
|
||||
siteName: config.SiteName,
|
||||
daysCount: expiresInDays,
|
||||
});
|
||||
|
||||
if (expiresAt) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Schedule Session Expiry Local Push Notification', expiresAt);
|
||||
PushNotifications.localNotificationSchedule({
|
||||
date: new Date(expiresAt),
|
||||
message,
|
||||
userInfo: {
|
||||
localNotification: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4,16 +4,12 @@
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
export function handleCommentDraftChanged(rootId, draft) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
if (state.views.thread.drafts[rootId]?.draft !== draft) {
|
||||
dispatch({
|
||||
type: ViewTypes.COMMENT_DRAFT_CHANGED,
|
||||
rootId,
|
||||
draft,
|
||||
});
|
||||
}
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ViewTypes.COMMENT_DRAFT_CHANGED,
|
||||
rootId,
|
||||
draft,
|
||||
}, getState);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,16 +17,10 @@ describe('Actions.Views.Thread', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({
|
||||
views: {
|
||||
thread: {
|
||||
drafts: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
store = mockStore({});
|
||||
});
|
||||
|
||||
test('handleCommentDraftChanged', () => {
|
||||
test('handleCommentDraftChanged', async () => {
|
||||
const rootId = '1234';
|
||||
const draft = 'draft1';
|
||||
const action = {
|
||||
@@ -34,11 +28,11 @@ describe('Actions.Views.Thread', () => {
|
||||
rootId,
|
||||
draft,
|
||||
};
|
||||
store.dispatch(handleCommentDraftChanged(rootId, draft));
|
||||
await store.dispatch(handleCommentDraftChanged(rootId, draft));
|
||||
expect(store.getActions()).toEqual([action]);
|
||||
});
|
||||
|
||||
test('handleCommentDraftSelectionChanged', () => {
|
||||
test('handleCommentDraftSelectionChanged', async () => {
|
||||
const rootId = '1234';
|
||||
const cursorPosition = 'position';
|
||||
const action = {
|
||||
@@ -46,7 +40,7 @@ describe('Actions.Views.Thread', () => {
|
||||
rootId,
|
||||
cursorPosition,
|
||||
};
|
||||
store.dispatch(handleCommentDraftSelectionChanged(rootId, cursorPosition));
|
||||
await store.dispatch(handleCommentDraftSelectionChanged(rootId, cursorPosition));
|
||||
expect(store.getActions()).toEqual([action]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {userTyping as wsUserTyping} from '@actions/websocket';
|
||||
import {userTyping as wsUserTyping} from 'mattermost-redux/actions/websocket';
|
||||
|
||||
export function userTyping(channelId, rootId) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {websocket} = state;
|
||||
const {websocket} = getState();
|
||||
if (websocket.connected) {
|
||||
wsUserTyping(state, channelId, rootId);
|
||||
wsUserTyping(channelId, rootId)(dispatch, getState);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,245 +1,20 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import {NavigationTypes} from 'app/constants';
|
||||
import {GeneralTypes, RoleTypes, UserTypes} from '@mm-redux/action_types';
|
||||
import {getDataRetentionPolicy} from '@mm-redux/actions/general';
|
||||
import * as HelperActions from '@mm-redux/actions/helpers';
|
||||
import {autoUpdateTimezone} from '@mm-redux/actions/timezone';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {General} from '@mm-redux/constants';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {getConfig, getLicense} from '@mm-redux/selectors/entities/general';
|
||||
import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone';
|
||||
import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities/users';
|
||||
|
||||
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;
|
||||
|
||||
export function completeLogin(user, deviceToken) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const config = getConfig(state);
|
||||
const license = getLicense(state);
|
||||
const token = Client4.getToken();
|
||||
const url = Client4.getUrl();
|
||||
|
||||
setAppCredentials(deviceToken, user.id, token, url);
|
||||
|
||||
// Set timezone
|
||||
const enableTimezone = isTimezoneEnabled(state);
|
||||
if (enableTimezone) {
|
||||
const timezone = getDeviceTimezone();
|
||||
dispatch(autoUpdateTimezone(timezone));
|
||||
}
|
||||
|
||||
// Data retention
|
||||
if (config?.DataRetentionEnableMessageDeletion && config?.DataRetentionEnableMessageDeletion === 'true' &&
|
||||
license?.IsLicensed === 'true' && license?.DataRetention === 'true') {
|
||||
dispatch(getDataRetentionPolicy());
|
||||
} else {
|
||||
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getMe() {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = {};
|
||||
data.me = await Client4.getMe();
|
||||
|
||||
const actions = [{
|
||||
type: UserTypes.RECEIVED_ME,
|
||||
data: data.me,
|
||||
}];
|
||||
|
||||
const roles = data.me.roles.split(' ');
|
||||
data.roles = await Client4.getRolesByNames(roles);
|
||||
if (data.roles.length) {
|
||||
actions.push({
|
||||
type: RoleTypes.RECEIVED_ROLES,
|
||||
data: data.roles,
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_ME'));
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadMe(user, deviceToken, skipDispatch = false) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const data = {user};
|
||||
const deviceId = state.entities?.general?.deviceToken;
|
||||
|
||||
try {
|
||||
if (deviceId && !deviceToken && !skipDispatch) {
|
||||
await Client4.attachDevice(deviceId);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
data.user = await Client4.getMe();
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(forceLogoutIfNecessary(error));
|
||||
return {error};
|
||||
}
|
||||
|
||||
try {
|
||||
analytics.setUserId(data.user.id);
|
||||
analytics.setUserRoles(data.user.roles);
|
||||
|
||||
// Execute all other requests in parallel
|
||||
const teamsRequest = Client4.getMyTeams();
|
||||
const teamMembersRequest = Client4.getMyTeamMembers();
|
||||
const teamUnreadRequest = Client4.getMyTeamUnreads();
|
||||
const preferencesRequest = Client4.getMyPreferences();
|
||||
const configRequest = Client4.getClientConfigOld();
|
||||
const actions = [];
|
||||
|
||||
const [teams, teamMembers, teamUnreads, preferences, config] = await Promise.all([
|
||||
teamsRequest,
|
||||
teamMembersRequest,
|
||||
teamUnreadRequest,
|
||||
preferencesRequest,
|
||||
configRequest,
|
||||
]);
|
||||
|
||||
data.teams = teams;
|
||||
data.teamMembers = teamMembers;
|
||||
data.teamUnreads = teamUnreads;
|
||||
data.preferences = preferences;
|
||||
data.config = config;
|
||||
data.url = Client4.getUrl();
|
||||
|
||||
actions.push({
|
||||
type: UserTypes.LOGIN,
|
||||
data,
|
||||
});
|
||||
|
||||
const rolesToLoad = new Set();
|
||||
for (const role of data.user.roles.split(' ')) {
|
||||
rolesToLoad.add(role);
|
||||
}
|
||||
|
||||
for (const teamMember of teamMembers) {
|
||||
for (const role of teamMember.roles.split(' ')) {
|
||||
rolesToLoad.add(role);
|
||||
}
|
||||
}
|
||||
if (rolesToLoad.size > 0) {
|
||||
data.roles = await Client4.getRolesByNames(Array.from(rolesToLoad));
|
||||
if (data.roles.length) {
|
||||
actions.push({
|
||||
type: RoleTypes.RECEIVED_ROLES,
|
||||
data: data.roles,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipDispatch) {
|
||||
dispatch(batchActions(actions, 'BATCH_LOAD_ME'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('login error', error.stack); // eslint-disable-line no-console
|
||||
return {error};
|
||||
}
|
||||
|
||||
return {data};
|
||||
};
|
||||
}
|
||||
|
||||
export function login(loginId, password, mfaToken, ldapOnly = false) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const deviceToken = state.entities?.general?.deviceToken;
|
||||
let user;
|
||||
|
||||
try {
|
||||
user = await Client4.login(loginId, password, mfaToken, deviceToken, ldapOnly);
|
||||
await setCSRFFromCookie(Client4.getUrl());
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
const result = await dispatch(loadMe(user));
|
||||
|
||||
if (!result.error) {
|
||||
dispatch(completeLogin(user, deviceToken));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function ssoLogin() {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const deviceToken = state.entities?.general?.deviceToken;
|
||||
const result = await dispatch(loadMe());
|
||||
|
||||
if (!result.error) {
|
||||
dispatch(completeLogin(result.data.user, deviceToken));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function logout(skipServerLogout = false) {
|
||||
return async () => {
|
||||
if (!skipServerLogout) {
|
||||
try {
|
||||
Client4.logout();
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
EventEmitter.emit(NavigationTypes.NAVIGATION_RESET);
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function forceLogoutIfNecessary(error) {
|
||||
return async (dispatch) => {
|
||||
if (error.status_code === HTTP_UNAUTHORIZED && error.url && !error.url.includes('/login')) {
|
||||
dispatch(logout(true));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
import {UserTypes} from 'mattermost-redux/action_types';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
export function setCurrentUserStatusOffline() {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const status = getStatusForUserId(state, currentUserId);
|
||||
const currentUserId = getCurrentUserId(getState());
|
||||
|
||||
if (status !== General.OFFLINE) {
|
||||
dispatch({
|
||||
type: UserTypes.RECEIVED_STATUS,
|
||||
data: {
|
||||
user_id: currentUserId,
|
||||
status: General.OFFLINE,
|
||||
},
|
||||
});
|
||||
}
|
||||
return dispatch({
|
||||
type: UserTypes.RECEIVED_STATUS,
|
||||
data: {
|
||||
user_id: currentUserId,
|
||||
status: General.OFFLINE,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/* eslint-disable no-import-assign */
|
||||
HelperActions.forceLogoutIfNecessary = forceLogoutIfNecessary;
|
||||
@@ -4,14 +4,14 @@
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import {UserTypes} from '@mm-redux/action_types';
|
||||
import {General} from '@mm-redux/constants';
|
||||
import {UserTypes} from 'mattermost-redux/action_types';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
|
||||
import {setCurrentUserStatusOffline} from 'app/actions/views/user';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
jest.mock('@mm-redux/actions/users', () => ({
|
||||
jest.mock('mattermost-redux/actions/users', () => ({
|
||||
getStatus: (...args) => ({type: 'MOCK_GET_STATUS', args}),
|
||||
getStatusesByIds: (...args) => ({type: 'MOCK_GET_STATUS_BY_IDS', args}),
|
||||
startPeriodicStatusUpdates: () => ({type: 'MOCK_PERIODIC_STATUS_UPDATES'}),
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable no-import-assign */
|
||||
|
||||
import assert from 'assert';
|
||||
import nock from 'nock';
|
||||
import {Server, WebSocket as MockWebSocket} from 'mock-socket';
|
||||
import 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);
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
// 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};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable no-import-assign */
|
||||
|
||||
import assert from 'assert';
|
||||
import {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');
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// 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};
|
||||
};
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
// 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};
|
||||
};
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable no-import-assign */
|
||||
|
||||
import assert from 'assert';
|
||||
import nock from 'nock';
|
||||
import {Server, WebSocket as MockWebSocket} from 'mock-socket';
|
||||
|
||||
import * 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);
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
// 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};
|
||||
};
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// 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};
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// 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]);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// 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]);
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
// 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};
|
||||
};
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user