Compare commits

..

54 Commits

Author SHA1 Message Date
Miguel Alatzar
41afce3e1d Bump app build number to 287 (#4229) (#4230) 2020-04-24 11:59:19 -07:00
Elias Nahum
87abcce7fa MM-24451 Set prev app version to current on logout/reset cache (#4224)
* MM-24451 Set prev app version to current on logout/reset cache

* Fix unit tests for release-1.30
2020-04-24 11:11:52 -07:00
Elias Nahum
8388ae1a30 Bump app build number to 286 and version to 1.30.1 (#4221)
* Bump app build number to 286

* Bump app version number to 1.30.1
2020-04-23 13:38:29 -04:00
Amit Uttam
605cc2afd8 Run message retention cleanup off of pre-existing state (#4211)
Instead of a reconstructed "zero" state.

Only posts in channels, searched posts and flag posts are recalculated (as per data retention policy, if applicable). The rest of state is cloned from existing state.
2020-04-23 12:57:58 -03:00
Mattermost Build
7f66fe84a7 Automated cherry pick of #4207 (#4213)
* MM-24385 Fix ExperimentalStrictCSRFEnforcement

* Fix ESLint errors

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-04-23 07:55:49 -04:00
Miguel Alatzar
da701268d5 [MM-24451] Handle setting previousVersion on logout and clearing data (#4205)
* Handle setting previousVersion on login and clearing data

* Fix unused import error

* Update test

* Just add previous version on logout
2020-04-22 21:04:24 -07:00
Miguel Alatzar
318cd13064 Check canPost permissions for v5.22+ (#4193) (#4201) 2020-04-22 20:32:12 -07:00
Mattermost Build
2fa572bcc7 Automated cherry pick of #4195 (#4206)
* Patch react-native-image-picker to allow selecting video

* Improve patch

* Fix Attaching a Photo capture

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-04-22 19:10:28 -04:00
Elias Nahum
976050ecc2 MM-24446 Fix Crash on iOS with EMM enabled (1.30) (#4204)
* Fix Crash on iOS with EMM enabled

* Fix Android Passcode authentication

* Fix Login screen header when EMM does not allow other servers
2020-04-22 19:09:32 -04:00
Miguel Alatzar
27411cb119 [MM-24426] Set previous app version in redux store (1.30) (#4196)
* Set previous app version in redux store

* Update state

* Log error
2020-04-22 08:23:45 -07:00
Miguel Alatzar
a17bc4683c Bump app build number to 285 (#4167) (#4168) 2020-04-16 19:35:34 -07:00
Mattermost Build
7f3798a547 Automated cherry pick of #4165 (#4166)
* Fix headers when preloading images

* Import isGif

Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-04-16 19:28:14 -07:00
Elias Nahum
2418a3bdfd Bump app build number to 284 (#4162) 2020-04-16 16:42:04 -04:00
Mattermost Build
7a4e7711d1 Automated cherry pick of #4160 (#4161)
* Correctly load file attachment images

* Remove cache control from gallery

* Remove unused import

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-04-16 16:33:45 -04:00
Elias Nahum
fc25935482 Bump app build number to 283 (#4159) 2020-04-16 09:01:30 -04:00
Elias Nahum
a02e536e35 MM-24202 Add ability to invalidate specific versions (#4158) 2020-04-16 08:53:20 -04:00
Mattermost Build
f610a1dd55 Bump app build number to 282 (#4155)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-04-14 16:03:00 -04:00
Elias Nahum
30c8d35966 Remove Client4 reference in progressive_image 2020-04-14 15:50:29 -04:00
Elias Nahum
5a2bb0125b Validate previous app version (#4151)
* Validate previous app version

* Update snapshot and fix typo in constant
2020-04-14 15:03:22 -04:00
Mattermost Build
8f2b8f3cc3 Automated cherry pick of #4150 (#4152)
* Ensure postProps is not null/undefined

* Disable camelcase check

Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-04-14 09:59:43 -07:00
Mattermost Build
7c66f4a0f7 Bump app build number to 281 (#4149)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-04-13 19:31:05 -04:00
Elias Nahum
957430c891 translations PR 20200413 (#4147) 2020-04-13 18:51:56 -04:00
Elias Nahum
93cbccf7bc MM-24093 Fix crash when system message does not contain a username in post props (#4143) 2020-04-13 16:55:25 -04:00
Mattermost Build
919ec06535 Bump app build number to 280 (#4140)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-04-09 15:10:32 -07:00
Mattermost Build
3f1f300aed Don't subtract from badge count (#4136)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-04-09 11:23:28 -07:00
Elias Nahum
518b021c8a translations PR 20200406 (#4123) 2020-04-08 11:02:29 -07:00
Mattermost Build
4a87f02833 MM-23927 patch RNFetchBlob to report progress using X-Uncompressed-Content-Length (#4128)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-04-07 19:08:24 -04:00
Mattermost Build
a6abc35ac5 Always show expand button (#4124)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-04-07 10:00:18 -07:00
Mattermost Build
cdf3baf40d Bump app build number to 279 (#4115)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-04-02 11:55:12 -07:00
Mattermost Build
7f35c843fd Tweak post list numbers (#4112)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-04-02 15:01:00 -03:00
Mattermost Build
beddfa645a Patch react-native-navigation to resolve promise when no modals to dismiss (#4107)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-04-01 13:56:02 -03:00
Mattermost Build
a1d175173b Automated cherry pick of #4100 (#4104)
* Check if HW keyboard is connected

* Call super

* Fix double new line insert

Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-03-31 21:52:54 -03:00
Elias Nahum
5c8db9b960 translations PR 20200330 (#4097) 2020-03-31 16:41:00 -03:00
Mattermost Build
f8cc0498fe MM-23687 Fix Android Share Extension (currently can't post) (#4096)
Fixes regression introduced in f5369ac9c5
2020-03-30 16:36:49 -03:00
Elias Nahum
e6099bcaf8 Fix post in channel batching order (#4089) 2020-03-30 15:02:35 -03:00
Elias Nahum
00a05b1671 fetch statuses for users in DM/GM's (#4086) 2020-03-30 14:19:47 -03:00
Mattermost Build
e1e86a8128 MM-23636: Default 'canPost' to true. (#4094)
Co-authored-by: Martin Kraft <martin@upspin.org>
2020-03-30 10:46:09 -03:00
Mattermost Build
3af9ea000f Revert "Use file URL over preview URL for GIFs (#3981)" (#4091)
This reverts commit cbc6ef185c.

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-27 23:09:39 -03:00
Mattermost Build
b2051bfc0f Automated cherry pick of #4079 (#4082)
* Update NOTICE.txt

* Update NOTICE.txt

Co-authored-by: Amy Blais <amy_blais@hotmail.com>
2020-03-27 09:48:07 -03:00
Elias Nahum
d893d0940b Bump app build number to 278 (#4081)
* Update mattermost-redux

* Update Fastlane

* Bump app build number to 278
2020-03-26 20:35:38 -03:00
Mattermost Build
f2cd57ef13 Automated cherry pick of #4075 (#4077)
* Don't use state for rowsSliced/colsSliced

* Update snapshot

* Also show moreBelow when contentHeight > MAX_HEIGHT

Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-03-25 16:05:21 -07:00
Mattermost Build
edb46db358 Fix Android Navbar tap events (#4076)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-24 20:45:46 -03:00
Elias Nahum
fd015697a2 translations PR 20200323 (#4063) 2020-03-24 19:00:11 -03:00
Mattermost Build
8bd4a49849 Automated cherry pick of #4064 (#4073)
* MM-23341 Fix message failed banner when posting first message

* Set switchToChannel flag back to false

* Feedback review

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-24 17:29:45 -03:00
Mattermost Build
b830b8ec93 Automated cherry pick of #4067 (#4071)
* Add backgroundColor

* Revert backgroundColor and set elevation instead

Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-24 13:43:12 -03:00
Mattermost Build
fe477f68d5 Automated cherry pick of #4062 (#4066)
* Enable FlatList virtualization

* Update snapshot test

Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-03-24 10:19:51 -03:00
Mattermost Build
1de5eb495d Fix Message attachment actions colors on dark themes (#4065)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-23 22:20:22 -03:00
Elias Nahum
6f98528b0a translations PR 20200319 (#4055) 2020-03-22 08:56:59 -03:00
Amit Uttam
1656fc1851 MM-23234 Prefer mobile-defined state actions (#4057)
Unify usage to one set of defined dispatch actions in codebase, instead of a mix of (almost) identical actions defined in `mattermost-mobile` and `mattermost-redux` .
2020-03-20 17:10:35 -03:00
Mattermost Build
a1d246c110 Automated cherry pick of #4051 (#4053)
* Bump app version number to 1.30.0

* Bump app build number to 277

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-19 14:36:57 -03:00
Mattermost Build
dfc4d82452 MM-21582 Parse payload safely to report Sentry exception with needed info (#4052)
Users are hitting legitimate exceptions when using the Android Share extension.

However, in some cases where the channel ID isn't available from the user's actions that triggered the exception, the Sentry error reporting message payload can't be successfully formed and it bombs.

This commit allows Sentry's payload to be successfully formed, so that we can now see the actual underlying exception (with its associated user data) in Sentry. The single, catch-all `NoSuchKeyException` should now stop, and be replaced by a few new Sentry exception reports to analyze and tackle individually.
2020-03-19 14:22:01 -03:00
Mattermost Build
1a831aac66 MM-23162 Remove unnecessary dimension and orientation dispatches (#4050)
`setDeviceOrientation` and `setDeviceDimensions` used to get called on any navigation change (e.g. modal launches) and triggered multiple needless dispatches.
2020-03-19 13:57:16 -03:00
Mattermost Build
e7e972b211 Update ios Podfile.lock (#4049)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-19 13:42:33 -03:00
Elias Nahum
1a1c73279b Upgrade Dependencies (#4034)
* Upgrade Navigation library

* Fix background color on Select Server

* Upgrade Navigation library

* Apply patch to nav lib

* Upgrade RNN to 6.1.1

* Update Dependencies

* Feedback review

* Call clearNavigationComponents when reset to channel
2020-03-18 19:09:20 -03:00
1815 changed files with 63407 additions and 182999 deletions

View File

@@ -1,7 +1,4 @@
version: 2.1
orbs:
owasp: entur/owasp@0.0.10
node: circleci/node@4.4.0
executors:
android:
@@ -14,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>>
@@ -24,7 +21,7 @@ executors:
NODE_ENV: production
BABEL_ENV: production
macos:
xcode: "12.1.0"
xcode: "11.0.0"
working_directory: ~/mattermost-mobile
shell: /bin/bash --login -o pipefail
@@ -45,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 }}
@@ -82,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 }}
@@ -92,8 +90,6 @@ commands:
npm-dependencies:
description: "Get JavaScript dependencies"
steps:
- node/install-npm:
version: '6.14.11'
- restore_cache:
name: Restore npm cache
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
@@ -106,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"
@@ -115,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 }}
@@ -147,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"
@@ -172,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"
@@ -205,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:
@@ -231,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-v7
- 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
@@ -291,7 +231,7 @@ jobs:
- build-android
- persist
- save:
filename: "*.apk"
filename: "Mattermost_Beta.apk"
build-android-release:
executor: android
@@ -299,7 +239,7 @@ jobs:
- build-android
- persist
- save:
filename: "*.apk"
filename: "Mattermost.apk"
build-android-pr:
executor: android
@@ -308,7 +248,7 @@ jobs:
steps:
- build-android
- save:
filename: "*.apk"
filename: "Mattermost_Beta.apk"
build-android-unsigned:
executor: android
@@ -320,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
@@ -330,7 +267,7 @@ jobs:
command: bundle exec fastlane android unsigned
- persist
- save:
filename: "*.apk"
filename: "Mattermost-unsigned.apk"
build-ios-beta:
executor: ios
@@ -338,7 +275,7 @@ jobs:
- build-ios
- persist
- save:
filename: "*.ipa"
filename: "Mattermost_Beta.ipa"
build-ios-release:
executor: ios
@@ -346,7 +283,7 @@ jobs:
- build-ios
- persist
- save:
filename: "*.ipa"
filename: "Mattermost.ipa"
build-ios-pr:
executor: ios
@@ -355,13 +292,14 @@ jobs:
steps:
- build-ios
- save:
filename: "*.ipa"
filename: "Mattermost_Beta.ipa"
build-ios-unsigned:
executor: ios
steps:
- checkout:
path: ~/mattermost-mobile
- ruby-setup
- npm-dependencies
- pods-dependencies
- assets
@@ -377,75 +315,56 @@ jobs:
- persist_to_workspace:
root: ~/
paths:
- mattermost-mobile/*.ipa
- mattermost-mobile/Mattermost-unsigned.ipa
- 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:
name: android
resource_class: medium
steps:
- ruby-setup
- attach_workspace:
at: ~/
- run:
@@ -458,10 +377,6 @@ workflows:
build:
jobs:
- test
# - check-deps:
# context: sast-webhook
# requires:
# - test
- build-android-release:
context: mattermost-mobile-android-release
@@ -553,14 +468,14 @@ workflows:
- test
filters:
branches:
only: /^(build|android)-pr-.*/
only: /^build-pr-.*/
- build-ios-pr:
context: mattermost-mobile-ios-pr
requires:
- test
filters:
branches:
only: /^(build|ios)-pr-.*/
only: /^build-pr-.*/
- build-android-unsigned:
context: mattermost-mobile-unsigned
@@ -580,18 +495,6 @@ workflows:
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+$/
- /^build-ios-sim-\d+$/
- github-release:
context: mattermost-mobile-unsigned
requires:
@@ -601,4 +504,4 @@ workflows:
tags:
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
branches:
only: unsigned
only: unsigned

2
.env
View File

@@ -1,2 +0,0 @@
STORYBOOK_PORT=
STORYBOOK_HOST=

View File

@@ -1,12 +1,8 @@
{
"extends": [
"plugin:mattermost/react",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
"plugin:mattermost/react"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"mattermost"
],
"settings": {
@@ -22,32 +18,9 @@
"__DEV__": true
},
"rules": {
"eol-last": ["error", "always"],
"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",
"no-use-before-define": "off",
"@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": [
{
@@ -55,23 +28,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
}
}
]
}

View File

@@ -8,6 +8,10 @@
; Ignore polyfills
node_modules/react-native/Libraries/polyfills/.*
; These should not be required directly
; require from fbjs/lib instead: require('fbjs/lib/warning')
node_modules/warning/.*
; Flow doesn't support platforms
.*/Libraries/Utilities/LoadingView.js
@@ -17,7 +21,7 @@ node_modules/react-native/Libraries/polyfills/.*
[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]
@@ -26,22 +30,25 @@ emoji=true
esproposal.optional_chaining=enable
esproposal.nullish_coalescing=enable
exact_by_default=true
module.file_ext=.js
module.file_ext=.json
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
suppress_type=$FlowFixMeProps
suppress_type=$FlowFixMeState
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(<VERSION>\\)? *\\(site=[a-z,_]*react_native\\(_ios\\)?_\\(oss\\|fb\\)[a-z,_]*\\)?)\\)
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(<VERSION>\\)? *\\(site=[a-z,_]*react_native\\(_ios\\)?_\\(oss\\|fb\\)[a-z,_]*\\)?)\\)?:? #[0-9]+
suppress_comment=\\(.\\|\n\\)*\\$FlowExpectedError
[lints]
sketchy-null-number=warn
sketchy-null-mixed=warn
@@ -53,6 +60,7 @@ unsafe-getters-setters=warn
inexact-spread=warn
unnecessary-invariant=warn
signature-verification-failure=warn
deprecated-utility=error
[strict]
deprecated-type
@@ -64,4 +72,4 @@ untyped-import
untyped-type-import
[version]
^0.137.0
^0.105.0

4
.gitattributes vendored
View File

@@ -1,3 +1 @@
# Windows files should use crlf line endings
# https://help.github.com/articles/dealing-with-line-endings/
*.bat text eol=crlf
*.pbxproj -text

View File

@@ -1,32 +0,0 @@
Per Mattermost guidelines, GitHub issues are for bug reports: <http://www.mattermost.org/filing-issues/>.
For troubleshooting see: http://forum.mattermost.org/.
For feature proposals see: http://www.mattermost.org/feature-requests/
If you've found a bug--something appears unintentional--please follow these steps:
1. Confirm youre filing a new issue. [Search existing tickets in Jira](https://mattermost.atlassian.net/jira/software/c/projects/MM/issues/) to ensure that the ticket does not already exist.
2. Confirm your issue does not involve security. Otherwise, please see our [Responsible Disclosure Policy](https://about.mattermost.com/report-security-issue/).
3. [File a new issue](https://github.com/mattermost/mattermost-mobile/issues/new) using the format below. Mattermost will confirm steps to reproduce and file in Jira, or ask for more details if there is trouble reproducing it. If there's already an existing bug in Jira, it will be linked back to the GitHub issue so you can track when it gets fixed.
#### Summary
Bug report in one concise sentence
### Environment Information
- Device Name:
- OS Version:
- Mattermost App Version:
- Mattermost Server Version:
#### Steps to reproduce
How can we reproduce the issue (what version are you using?)
#### Expected behavior
Describe your issue in detail
#### Observed behavior (that appears unintentional)
What did you see happen? Please include relevant error messages, screenshots and/or video recordings.
#### Possible fixes
If you can, link to the line of code that might be responsible for the problem

View File

@@ -1,61 +0,0 @@
<!-- Thank you for contributing a pull request! Here are a few tips to help you:
1. If this is your first contribution, make sure you've read the Contribution Checklist https://developers.mattermost.com/contribute/getting-started/contribution-checklist/
2. Read our blog post about "Submitting Great PRs" https://developers.mattermost.com/blog/2019-01-24-submitting-great-prs
3. Take a look at other repository specific documentation at https://developers.mattermost.com/contribute
-->
#### Summary
<!--
A brief description of what this pull request does.
-->
#### Ticket Link
<!--
If this pull request addresses a Help Wanted ticket or fixes a reported issue, please link the relevant GitHub issue, e.g.
Fixes https://github.com/mattermost/mattermost-mobile/issues/XXXXX
Otherwise, link the JIRA ticket.
-->
#### Checklist
<!--
Place an '[x]' (no spaces) in all applicable fields. Please remove unrelated fields.
-->
- [ ] Added or updated unit tests (required for all new features)
- [ ] Has UI changes
- [ ] Includes text changes and localization file updates
#### Device Information
This PR was tested on: <!-- Device name(s), OS version(s) -->
#### Screenshots
<!--
If the PR includes UI changes, include screenshots/GIFs/Videos (for both iOS and Android if possible).
-->
#### Release Note
<!--
Add a release note for each of the following conditions:
* New features and improvements, including behavioural changes, UI changes
* Bug fixes and fixes of previous known issues
* Deprecation warnings, breaking changes, or compatibility notes
If no release notes are required write NONE. Use past-tense. Newlines are stripped.
Example:
```release-note
Added a new config setting ServiceSettings.FooBar. Added a new column Foo to the Users table.
```
```release-note
NONE
```
-->
```release-note
```

View File

@@ -1,43 +0,0 @@
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '0 0 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

9
.gitignore vendored
View File

@@ -5,8 +5,6 @@ build-ios
server.PID
mattermost.keystore
tmp/
.env
env.d.ts
# OSX
#
@@ -89,13 +87,6 @@ ios/sentry.properties
# Testing
.nyc_output
coverage
.tmp
# E2E testing
mattermost-license.txt
*.mattermost-license
detox/artifacts
detox/detox_pixel_4_xl_api_30
# Bundle artifact
*.jsbundle

1
.husky/.gitignore vendored
View File

@@ -1 +0,0 @@
_

View File

@@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
sh ./scripts/pre-commit.sh

View File

@@ -1,6 +0,0 @@
module.exports = {
"stories": [
"../app/components/**/*.stories.mdx",
"../app/components/**/*.stories.@(js|jsx|ts|tsx)"
],
}

View File

@@ -1,5 +0,0 @@
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
source "https://rubygems.org"
gem "cocoapods", "1.10.1"
gem "cocoapods", "1.7.5"

View File

@@ -1,28 +1,24 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.3)
activesupport (5.2.5)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
CFPropertyList (3.0.2)
activesupport (4.2.11.1)
i18n (~> 0.7)
minitest (~> 5.1)
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
atomos (0.1.3)
claide (1.0.3)
cocoapods (1.10.1)
addressable (~> 2.6)
cocoapods (1.7.5)
activesupport (>= 4.0.2, < 5)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.10.1)
cocoapods-core (= 1.7.5)
cocoapods-deintegrate (>= 1.0.3, < 2.0)
cocoapods-downloader (>= 1.4.0, < 2.0)
cocoapods-downloader (>= 1.2.2, < 2.0)
cocoapods-plugins (>= 1.0.0, < 2.0)
cocoapods-search (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.4.0, < 2.0)
cocoapods-stats (>= 1.0.0, < 2.0)
cocoapods-trunk (>= 1.3.1, < 2.0)
cocoapods-try (>= 1.1.0, < 2.0)
colored2 (~> 3.1)
escape (~> 0.0.4)
@@ -31,63 +27,50 @@ GEM
molinillo (~> 0.6.6)
nap (~> 1.0)
ruby-macho (~> 1.4)
xcodeproj (>= 1.19.0, < 2.0)
cocoapods-core (1.10.1)
activesupport (> 5.0, < 6)
addressable (~> 2.6)
algoliasearch (~> 1.0)
concurrent-ruby (~> 1.1)
xcodeproj (>= 1.10.0, < 2.0)
cocoapods-core (1.7.5)
activesupport (>= 4.0.2, < 6)
fuzzy_match (~> 2.0.4)
nap (~> 1.0)
netrc (~> 0.11)
public_suffix
typhoeus (~> 1.0)
cocoapods-deintegrate (1.0.4)
cocoapods-downloader (1.4.0)
cocoapods-downloader (1.3.0)
cocoapods-plugins (1.0.0)
nap
cocoapods-search (1.0.0)
cocoapods-trunk (1.5.0)
cocoapods-stats (1.1.0)
cocoapods-trunk (1.4.1)
nap (>= 0.8, < 2.0)
netrc (~> 0.11)
cocoapods-try (1.2.0)
cocoapods-try (1.1.0)
colored2 (3.1.2)
concurrent-ruby (1.1.8)
concurrent-ruby (1.1.5)
escape (0.0.4)
ethon (0.12.0)
ffi (>= 1.3.0)
ffi (1.15.0)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
httpclient (2.8.3)
i18n (1.8.10)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
json (2.5.1)
minitest (5.14.4)
minitest (5.14.0)
molinillo (0.6.6)
nanaimo (0.3.0)
nanaimo (0.2.6)
nap (1.1.0)
netrc (0.11.0)
public_suffix (4.0.6)
ruby-macho (1.4.0)
thread_safe (0.3.6)
typhoeus (1.4.0)
ethon (>= 0.9.0)
tzinfo (1.2.9)
tzinfo (1.2.6)
thread_safe (~> 0.1)
xcodeproj (1.19.0)
xcodeproj (1.15.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
nanaimo (~> 0.2.6)
PLATFORMS
ruby
DEPENDENCIES
cocoapods (= 1.10.1)
cocoapods (= 1.7.5)
BUNDLED WITH
2.1.4
2.0.2

14
Jenkinsfile vendored Normal file
View 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'
}
}
}
}

251
Makefile Normal file
View File

@@ -0,0 +1,251 @@
.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
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)
@echo "Required version of Cocoapods is not installed"
@echo Installing gems;
@bundle install
@echo Getting Cocoapods dependencies;
@cd ios && bundle exec pod install;
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

1218
NOTICE.txt

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,7 @@
# Mattermost Mobile
- **Minimum Server versions:** Current ESR version (5.31.3)
- **Supported iOS versions:** 11+
- **Minimum Server versions:** Current ESR version (5.19)
- **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).
@@ -63,7 +63,7 @@ We plan to add support for tablets in the future, but the timeline depends on ho
### I keep getting a message "Cannot connect to the server. Please check your server URL and internet connection."
This sometimes appears when there is an issue with the SSL certificate configuration.
This sometimes appears when there is an issue with the SSL certitificate configuration.
To check that your SSL certificate is set up correctly, test the SSL certificate by visiting a site such as https://www.ssllabs.com/ssltest/index.html. If theres an error about the missing chain or certificate path, there is likely an intermediate certificate missing that needs to be included.

View File

@@ -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
@@ -101,14 +99,15 @@ 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:+'
// Add v8-android - prebuilt libv8android.so into APK
def jscFlavor = 'org.chromium:v8-android:+'
/**
* Whether to enable the Hermes VM.
@@ -131,12 +130,13 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
versionCode 360
versionName "1.44.1"
versionCode 287
versionName "1.30.1"
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 +152,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 +181,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
}
}
}
@@ -192,9 +192,9 @@ android {
}
packagingOptions {
pickFirst '**/*.so'
// Make sure libjsc.so does not packed in APK
exclude "**/libjsc.so"
}
}
repositories {
@@ -223,23 +223,6 @@ 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")
@@ -249,12 +232,14 @@ dependencies {
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.4'
implementation project(':reactnativenotifications')
implementation "com.google.firebase:firebase-messaging:$firebaseVersion"
// For animated GIF support
implementation 'com.facebook.fresco:fresco:2.0.0'
@@ -262,8 +247,6 @@ dependencies {
// 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

View File

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

View File

@@ -4,10 +4,5 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application
android:usesCleartextTraffic="true"
tools:targetApi="28"
tools:ignore="GoogleAppIndexingWarning">
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
</application>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" />
</manifest>

View File

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

View File

@@ -19,20 +19,18 @@
android:theme="@style/AppTheme"
android:installLocation="auto"
android:networkSecurityConfig="@xml/network_security_config"
android:resizeableActivity="true"
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true"
>
<meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
<meta-data android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
<meta-data android:name="com.wix.reactnativenotifications.gcmSenderId" android:value="184930218130\"/>
<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:taskAffinity="">
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -44,13 +42,8 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mattermost" />
</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="mmauthbeta" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
<service android:name=".NotificationDismissService"
android:enabled="true"
android:exported="false" />

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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);
}
@@ -300,11 +292,11 @@ public class CustomPushNotification extends PushNotification {
private Notification.MessagingStyle getMessagingStyle(Bundle bundle) {
Notification.MessagingStyle messagingStyle;
String senderId = bundle.getString("sender_id");
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P || senderId == null) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
messagingStyle = new Notification.MessagingStyle("");
} else {
String senderId = bundle.getString("sender_id");
Person sender = new Person.Builder()
.setKey(senderId)
.setName("")
@@ -364,7 +356,7 @@ public class CustomPushNotification extends PushNotification {
int bundleCount = bundleList.size() - 1;
for (int i = bundleCount; i >= 0; i--) {
Bundle data = bundleList.get(i);
String message = data.getString("message", data.getString("body"));
String message = data.getString("message");
String senderId = data.getString("sender_id");
if (senderId == null) {
senderId = "sender_id";
@@ -372,7 +364,7 @@ public class CustomPushNotification extends PushNotification {
Bundle userInfoBundle = data.getBundle("userInfo");
String senderName = getSenderName(data);
if (userInfoBundle != null) {
boolean localPushNotificationTest = userInfoBundle.getBoolean("test");
boolean localPushNotificationTest = userInfoBundle.getBoolean("localTest");
if (localPushNotificationTest) {
senderName = "Test";
}
@@ -403,15 +395,13 @@ public class CustomPushNotification extends PushNotification {
NotificationChannel notificationChannel = mHighImportanceChannel;
boolean testNotification = false;
boolean localNotification = false;
boolean localPushNotificationTest = false;
Bundle userInfoBundle = bundle.getBundle("userInfo");
if (userInfoBundle != null) {
testNotification = userInfoBundle.getBoolean("test");
localNotification = userInfoBundle.getBoolean("local");
localPushNotificationTest = userInfoBundle.getBoolean("localTest");
}
if (mAppLifecycleFacade.isAppVisible() && !testNotification && !localNotification) {
if (mAppLifecycleFacade.isAppVisible() && !localPushNotificationTest) {
notificationChannel = mMinImportanceChannel;
}
@@ -469,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;
}
@@ -536,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) {

View File

@@ -5,7 +5,6 @@ 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.List;
@@ -25,7 +24,6 @@ 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;
@@ -33,16 +31,14 @@ import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactMarker;
import com.facebook.react.bridge.ReactMarkerConstants;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.module.model.ReactModuleInfo;
import com.facebook.react.module.model.ReactModuleInfoProvider;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.facebook.soloader.SoLoader;
import com.facebook.react.bridge.JSIModulePackage;
import com.swmansion.reanimated.ReanimatedJSIModulePackage;
public class MainApplication extends NavigationApplication implements INotificationsApplication, INotificationsDrawerApplication {
public static MainApplication instance;
@@ -115,11 +111,6 @@ private final ReactNativeHost mReactNativeHost =
protected String getJSMainModuleName() {
return "index";
}
@Override
protected JSIModulePackage getJSIModulePackage() {
return new ReanimatedJSIModulePackage();
}
};
@Override
@@ -138,7 +129,9 @@ private final ReactNativeHost mReactNativeHost =
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();
}
@Override
@@ -199,34 +192,35 @@ private final ReactNativeHost mReactNativeHost =
return null;
}
/**
* 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.rn.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();
private void addReactMarkerListener() {
ReactMarker.addListener(new ReactMarker.MarkerListener() {
@Override
public void logMarker(ReactMarkerConstants name, @Nullable String tag, int instanceKey) {
if (name.toString() == ReactMarkerConstants.RELOAD.toString()) {
APP_START_TIME = System.currentTimeMillis();
RELOAD = System.currentTimeMillis();
} else if (name.toString() == ReactMarkerConstants.PROCESS_PACKAGES_START.toString()) {
PROCESS_PACKAGES_START = System.currentTimeMillis();
} else if (name.toString() == ReactMarkerConstants.PROCESS_PACKAGES_END.toString()) {
PROCESS_PACKAGES_END = System.currentTimeMillis();
} else if (name.toString() == ReactMarkerConstants.CONTENT_APPEARED.toString()) {
CONTENT_APPEARED = System.currentTimeMillis();
ReactContext ctx = getRunningReactContext();
if (ctx != null) {
WritableMap map = Arguments.createMap();
map.putDouble("appReload", RELOAD);
map.putDouble("appContentAppeared", CONTENT_APPEARED);
map.putDouble("processPackagesStart", PROCESS_PACKAGES_START);
map.putDouble("processPackagesEnd", PROCESS_PACKAGES_END);
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).
emit("nativeMetrics", map);
}
}
}
}
});
}
}

View File

@@ -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;
@@ -16,20 +15,12 @@ import com.mattermost.react_native_interface.KeysReadableArray;
public class MattermostCredentialsHelper {
static final String CURRENT_SERVER_URL = "@currentServerUrl";
static KeychainModule keychainModule;
static AsyncStorageHelper asyncStorage;
public static void getCredentialsForCurrentServer(ReactApplicationContext context, ResolvePromise promise) {
final KeychainModule keychainModule = new KeychainModule(context);
final AsyncStorageHelper asyncStorage = new AsyncStorageHelper(context);
final ArrayList<String> keys = new ArrayList<String>(1);
keys.add(CURRENT_SERVER_URL);
if (keychainModule == null) {
keychainModule = new KeychainModule(context);
}
if (asyncStorage == null) {
asyncStorage = new AsyncStorageHelper(context);
}
KeysReadableArray asyncStorageKeys = new KeysReadableArray() {
@Override
public int size() {
@@ -44,14 +35,7 @@ public class MattermostCredentialsHelper {
HashMap<String, String> asyncStorageResults = asyncStorage.multiGet(asyncStorageKeys);
String serverUrl = asyncStorageResults.get(CURRENT_SERVER_URL);
final WritableMap options = Arguments.createMap();
// KeyChain module fails if `authenticationPrompt` is not set
final WritableMap authPrompt = Arguments.createMap();
authPrompt.putString("title", "Authenticate to retrieve secret");
authPrompt.putString("cancel", "Cancel");
options.putMap("authenticationPrompt", authPrompt);
options.putString("service", serverUrl);
keychainModule.getGenericPasswordForOptions(options, promise);
keychainModule.getGenericPasswordForOptions(serverUrl, promise);
}
}

View File

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

View File

@@ -20,7 +20,6 @@ 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;
@@ -139,14 +138,13 @@ public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteList
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;
}

View File

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

View File

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

View File

@@ -3,9 +3,11 @@ package com.mattermost.share;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.DocumentsContract;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.content.ContentUris;
import android.content.ContentResolver;
import android.os.Environment;
import android.webkit.MimeTypeMap;
@@ -13,18 +15,18 @@ import android.util.Log;
import android.text.TextUtils;
import android.os.ParcelFileDescriptor;
import java.io.*;
import java.nio.channels.FileChannel;
import java.util.Objects;
// Class based on the steveevers DocumentHelper https://gist.github.com/steveevers/a5af24c226f44bb8fdc3
public class RealPathUtil {
public static String getRealPathFromURI(final Context context, final Uri uri) {
final boolean isKitKatOrNewer = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
// DocumentProvider
if (DocumentsContract.isDocumentUri(context, uri)) {
if (isKitKatOrNewer && DocumentsContract.isDocumentUri(context, uri)) {
// ExternalStorageProvider
if (isExternalStorageDocument(uri)) {
final String docId = DocumentsContract.getDocumentId(uri);
@@ -70,11 +72,7 @@ public class RealPathUtil {
split[1]
};
if (contentUri != null) {
return getDataColumn(context, contentUri, selection, selectionArgs);
} else {
return getPathFromSavingTempFile(context, uri);
}
return getDataColumn(context, contentUri, selection, selectionArgs);
}
}
@@ -98,26 +96,20 @@ public class RealPathUtil {
File tmpFile;
String fileName = null;
if (uri == null || uri.isRelative()) {
return null;
}
// Try and get the filename from the Uri
try {
Cursor returnCursor =
context.getContentResolver().query(uri, null, null, null, null);
int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
returnCursor.moveToFirst();
fileName = sanitizeFilename(returnCursor.getString(nameIndex));
returnCursor.close();
fileName = returnCursor.getString(nameIndex);
} catch (Exception e) {
// just continue to get the filename with the last segment of the path
}
try {
if (TextUtils.isEmpty(fileName)) {
fileName = sanitizeFilename(uri.getLastPathSegment().trim());
if (fileName == null) {
fileName = uri.getLastPathSegment().toString().trim();
}
@@ -126,6 +118,7 @@ public class RealPathUtil {
cacheDir.mkdirs();
}
String mimeType = getMimeType(uri.getPath());
tmpFile = new File(cacheDir, fileName);
tmpFile.createNewFile();
@@ -232,18 +225,9 @@ public class RealPathUtil {
private static void deleteRecursive(File fileOrDirectory) {
if (fileOrDirectory.isDirectory())
for (File child : Objects.requireNonNull(fileOrDirectory.listFiles()))
for (File child : fileOrDirectory.listFiles())
deleteRecursive(child);
fileOrDirectory.delete();
}
private static String sanitizeFilename(String filename) {
if (filename == null) {
return null;
}
File f = new File(filename);
return f.getName();
}
}

View File

@@ -27,7 +27,6 @@ import org.json.JSONException;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
@@ -46,7 +45,6 @@ public class ShareModule extends ReactContextBaseJavaModule {
super(reactContext);
mApplication = application;
}
private File tempFolder;
@Override
@@ -133,7 +131,6 @@ public class ShareModule extends ReactContextBaseJavaModule {
String text = "";
String type = "";
String action = "";
String extra = "";
Activity currentActivity = getCurrentActivity();
@@ -142,21 +139,20 @@ public class ShareModule extends ReactContextBaseJavaModule {
Intent intent = currentActivity.getIntent();
action = intent.getAction();
type = intent.getType();
extra = intent.getStringExtra(Intent.EXTRA_TEXT);
if (type == null) {
type = "";
}
if (Intent.ACTION_SEND.equals(action) && "text/plain".equals(type) && extra != null) {
map.putString("value", extra);
if (Intent.ACTION_SEND.equals(action) && "text/plain".equals(type)) {
text = intent.getStringExtra(Intent.EXTRA_TEXT);
map.putString("value", text);
map.putString("type", type);
map.putBoolean("isString", true);
items.pushMap(map);
} else if (Intent.ACTION_SEND.equals(action)) {
Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (uri != null) {
map.putString("value", "file://" + RealPathUtil.getRealPathFromURI(currentActivity, uri));
text = "file://" + RealPathUtil.getRealPathFromURI(currentActivity, uri);
map.putString("value", text);
if (type.equals("image/*")) {
type = "image/jpeg";
@@ -165,16 +161,17 @@ public class ShareModule extends ReactContextBaseJavaModule {
}
map.putString("type", type);
map.putBoolean("isString", false);
items.pushMap(map);
}
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
ArrayList<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
for (Uri uri : Objects.requireNonNull(uris)) {
for (Uri uri : uris) {
String filePath = RealPathUtil.getRealPathFromURI(currentActivity, uri);
map = Arguments.createMap();
map.putString("value", "file://" + RealPathUtil.getRealPathFromURI(currentActivity, uri));
type = RealPathUtil.getMimeTypeFromUri(currentActivity, uri);
text = "file://" + filePath;
map.putString("value", text);
type = RealPathUtil.getMimeTypeFromUri(currentActivity, uri);
if (type != null) {
if (type.equals("image/*")) {
type = "image/jpeg";
@@ -185,7 +182,6 @@ public class ShareModule extends ReactContextBaseJavaModule {
type = "application/octet-stream";
}
map.putString("type", type);
map.putBoolean("isString", false);
items.pushMap(map);
}
}
@@ -225,7 +221,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
MultipartBody.Builder builder = new MultipartBody.Builder()
.setType(MultipartBody.FORM);
for (int i = 0; i < files.size(); i++) {
for(int i = 0 ; i < files.size() ; i++) {
ReadableMap file = files.getMap(i);
String filePath = file.getString("fullPath").replaceFirst("file://", "");
File fileInfo = new File(filePath);
@@ -249,7 +245,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
JSONObject responseJson = new JSONObject(responseData);
JSONArray fileInfoArray = responseJson.getJSONArray("file_infos");
JSONArray file_ids = new JSONArray();
for (int i = 0; i < fileInfoArray.length(); i++) {
for(int i = 0 ; i < fileInfoArray.length() ; i++) {
JSONObject fileInfo = fileInfoArray.getJSONObject(i);
file_ids.put(fileInfo.getString("id"));
}

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="white">#FFFFFF</color>
<color name="transparent">#00000000</color>
</resources>

View File

@@ -14,10 +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>
<string name="inAppSessionAuth_title">In-App Session Auth</string>
<string name="inAppSessionAuth_description">Instead of default flow from the mobile browser, enforce SSO with the WebView.</string>
</resources>

View File

@@ -1,10 +1,8 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowBackground">@android:color/transparent</item>
</style>
</resources>

View File

@@ -7,12 +7,6 @@
android:description="@string/inAppPinCode_description"
android:restrictionType="string"
android:defaultValue="false" />
<restriction
android:key="inAppSessionAuth"
android:title="@string/inAppSessionAuth_title"
android:description="@string/inAppSessionAuth_description"
android:restrictionType="string"
android:defaultValue="false" />
<restriction
android:key="blurApplicationScreen"
android:title="@string/blurApplicationScreen_title"
@@ -49,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"

View File

@@ -2,15 +2,13 @@
buildscript {
ext {
buildToolsVersion = "29.0.3"
buildToolsVersion = "28.0.3"
minSdkVersion = 24
compileSdkVersion = 29
targetSdkVersion = 29
compileSdkVersion = 28
targetSdkVersion = 28
supportLibVersion = "28.0.0"
kotlinVersion = "1.3.61"
firebaseVersion = "21.0.0"
RNNKotlinVersion = kotlinVersion
ndkVersion = "21.1.6352462"
}
repositories {
@@ -20,15 +18,28 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.0'
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"
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
}
}
subprojects {
afterEvaluate {
android {
compileSdkVersion rootProject.ext.compileSdkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
}
}
}
}
allprojects {
repositories {
google()
@@ -37,26 +48,20 @@ allprojects {
jcenter()
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url("$rootDir/../node_modules/react-native/android")
// 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-v8/dist")
}
maven {
// Local Maven repo containing AARs with JSC library built for Android
url("$rootDir/../node_modules/jsc-android/dist")
// url "$rootDir/../node_modules/jsc-android/dist"
// prebuilt libv8android.so
// url("$rootDir/../node_modules/v8-android/dist")
url("$rootDir/../node_modules/v8-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"
}
}
}

View File

@@ -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.75.1
android.enableJetifier=true

Binary file not shown.

View File

@@ -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.7-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-5.5-all.zip

53
android/gradlew vendored
View File

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

173
android/gradlew.bat vendored
View File

@@ -1,89 +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 execute
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 execute
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
: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 %*
: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

View File

@@ -1,5 +1,5 @@
rootProject.name = 'Mattermost'
include ':reactnativenotifications'
project(':reactnativenotifications').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-notifications/lib/android/app')
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'

View File

@@ -1,165 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@client/rest';
import {ActionFunc, DispatchFunc} from '@mm-redux/types/actions';
import {AppCallResponse, AppForm, AppCallRequest, AppCallType, AppContext} from '@mm-redux/types/apps';
import {Post} from '@mm-redux/types/posts';
import {AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {showModal} from './navigation';
import {Theme} from '@mm-redux/types/preferences';
import CompassIcon from '@components/compass_icon';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import EphemeralStore from '@store/ephemeral_store';
import {makeCallErrorResponse} from '@utils/apps';
import {sendEphemeralPost} from '@actions/views/post';
import {CommandArgs} from '@mm-redux/types/integrations';
export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc {
return async (dispatch, getState) => {
try {
const res = await Client4.executeAppCall(call, type) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
case AppCallResponseTypes.OK:
return {data: res};
case AppCallResponseTypes.ERROR:
return {error: res};
case AppCallResponseTypes.FORM: {
if (!res.form) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no form was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
const screen = EphemeralStore.getNavigationTopComponentId();
if (type === AppCallTypes.SUBMIT && screen !== 'AppForm') {
showAppForm(res.form, call, getTheme(getState()));
}
return {data: res};
}
case AppCallResponseTypes.NAVIGATE:
if (!res.navigate_to_url) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.navigate.no_url',
defaultMessage: 'Response type is `navigate`, but no url was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
if (type !== AppCallTypes.SUBMIT) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.navigate.no_submit',
defaultMessage: 'Response type is `navigate`, but the call was not a submission.',
});
return {error: makeCallErrorResponse(errMsg)};
}
dispatch(handleGotoLocation(res.navigate_to_url, intl));
return {data: res};
default: {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: responseType,
});
return {error: makeCallErrorResponse(errMsg)};
}
}
} catch (error) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
return {error: makeCallErrorResponse(errMsg)};
}
};
}
const showAppForm = async (form: AppForm, call: AppCallRequest, theme: Theme) => {
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
let submitButtons = [{
id: 'submit-form',
showAsAction: 'always',
text: 'Submit',
}];
if (form.submit_buttons) {
const options = form.fields.find((f) => f.name === form.submit_buttons)?.options;
const newButtons = options?.map((o) => {
return {
id: 'submit-form_' + o.value,
showAsAction: 'always',
text: o.label,
};
});
if (newButtons && newButtons.length > 0) {
submitButtons = newButtons;
}
}
const options = {
topBar: {
leftButtons: [{
id: 'close-dialog',
icon: closeButton,
}],
rightButtons: submitButtons,
},
};
const passProps = {form, call};
showModal('AppForm', form.title, passProps, options);
};
export function postEphemeralCallResponseForPost(response: AppCallResponse, message: string, post: Post): ActionFunc {
return (dispatch: DispatchFunc) => {
return dispatch(sendEphemeralPost(
message,
post.channel_id,
post.root_id || post.id,
response.app_metadata?.bot_user_id,
));
};
}
export function postEphemeralCallResponseForChannel(response: AppCallResponse, message: string, channelID: string): ActionFunc {
return (dispatch: DispatchFunc) => {
return dispatch(sendEphemeralPost(
message,
channelID,
'',
response.app_metadata?.bot_user_id,
));
};
}
export function postEphemeralCallResponseForContext(response: AppCallResponse, message: string, context: AppContext): ActionFunc {
return (dispatch: DispatchFunc) => {
return dispatch(sendEphemeralPost(
message,
context.channel_id,
context.root_id || context.post_id,
response.app_metadata?.bot_user_id,
));
};
}
export function postEphemeralCallResponseForCommandArgs(response: AppCallResponse, message: string, args: CommandArgs): ActionFunc {
return (dispatch: DispatchFunc) => {
return dispatch(sendEphemeralPost(
message,
args.channel_id,
args.root_id,
response.app_metadata?.bot_user_id,
));
};
}

View File

@@ -1,17 +1,24 @@
// 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,
});
};
}
export function setStatusBarHeight(height = 20) {
return {
type: DeviceTypes.STATUSBAR_HEIGHT_CHANGED,
data: height,
};
}
@@ -44,4 +51,5 @@ export default {
setDeviceDimensions,
setDeviceOrientation,
setDeviceAsTablet,
setStatusBarHeight,
};

View File

@@ -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 '@client/rest';
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();
});
});
});

View File

@@ -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 '@client/rest';
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;
}
}

View File

@@ -5,23 +5,15 @@ import {Keyboard, 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 {DeviceTypes, NavigationTypes} from '@constants';
import {CHANNEL} from '@constants/screen';
import EphemeralStore from '@store/ephemeral_store';
import Store from '@store/store';
import store from 'app/store';
import EphemeralStore from 'app/store/ephemeral_store';
Navigation.setDefaultOptions({
layout: {
orientation: [DeviceTypes.IS_TABLET ? 'all' : 'portrait'],
},
});
const CHANNEL_SCREEN = 'Channel';
function getThemeFromState() {
const state = Store.redux?.getState() || {};
const state = store.getState();
return getTheme(state);
}
@@ -34,8 +26,8 @@ export function resetToChannel(passProps = {}) {
const stack = {
children: [{
component: {
id: CHANNEL,
name: CHANNEL,
id: CHANNEL_SCREEN,
name: CHANNEL_SCREEN,
passProps,
options: {
layout: {
@@ -52,8 +44,6 @@ export function resetToChannel(passProps = {}) {
},
backButton: {
visible: false,
color: theme.sidebarHeaderTextColor,
enableMenu: false,
},
},
},
@@ -92,9 +82,7 @@ export function resetToChannel(passProps = {}) {
}
export function resetToSelectServer(allowOtherServers) {
const theme = Preferences.THEMES.default;
EphemeralStore.clearNavigationComponents();
const theme = getThemeFromState();
Navigation.setRoot({
root: {
@@ -117,7 +105,6 @@ export function resetToSelectServer(allowOtherServers) {
topBar: {
backButton: {
color: theme.sidebarHeaderTextColor,
enableMenu: false,
title: '',
},
background: {
@@ -151,7 +138,6 @@ export function resetToTeams(name, title, passProps = {}, options = {}) {
},
backButton: {
color: theme.sidebarHeaderTextColor,
enableMenu: false,
title: '',
},
background: {
@@ -160,8 +146,6 @@ export function resetToTeams(name, title, passProps = {}, options = {}) {
},
};
EphemeralStore.clearNavigationComponents();
Navigation.setRoot({
root: {
stack: {
@@ -195,9 +179,7 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
visible: true,
backButton: {
color: theme.sidebarHeaderTextColor,
enableMenu: false,
title: '',
testID: 'screen.back.button',
},
background: {
color: theme.sidebarHeaderBg,
@@ -239,17 +221,10 @@ 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'}),
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
layout: {
componentBackgroundColor: theme.centerChannelBg,
},
@@ -261,7 +236,6 @@ export function showModal(name, title, passProps = {}, options = {}) {
visible: true,
backButton: {
color: theme.sidebarHeaderTextColor,
enableMenu: false,
title: '',
},
background: {
@@ -276,7 +250,6 @@ export function showModal(name, title, passProps = {}, options = {}) {
},
};
EphemeralStore.addNavigationModal(name);
Navigation.showModal({
stack: {
children: [{
@@ -338,46 +311,28 @@ 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.
}
}
export async function dismissAllModals(options) {
if (!EphemeralStore.hasModalsOpened()) {
return;
}
if (Platform.OS === 'ios') {
const modals = [...EphemeralStore.navigationModalStack];
for await (const modal of modals) {
await Navigation.dismissModal(modal, options);
EphemeralStore.removeNavigationModal(modal);
}
} else {
export async function dismissAllModals(options = {}) {
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.
}
}
@@ -445,7 +400,7 @@ export function closeMainSideMenu() {
}
Keyboard.dismiss();
Navigation.mergeOptions(CHANNEL, {
Navigation.mergeOptions(CHANNEL_SCREEN, {
sideMenu: {
left: {visible: false},
},
@@ -457,7 +412,7 @@ export function enableMainSideMenu(enabled, visible = true) {
return;
}
Navigation.mergeOptions(CHANNEL, {
Navigation.mergeOptions(CHANNEL_SCREEN, {
sideMenu: {
left: {enabled, visible},
},
@@ -470,7 +425,7 @@ export function openSettingsSideMenu() {
}
Keyboard.dismiss();
Navigation.mergeOptions(CHANNEL, {
Navigation.mergeOptions(CHANNEL_SCREEN, {
sideMenu: {
right: {visible: true},
},
@@ -483,7 +438,7 @@ export function closeSettingsSideMenu() {
}
Keyboard.dismiss();
Navigation.mergeOptions(CHANNEL, {
Navigation.mergeOptions(CHANNEL_SCREEN, {
sideMenu: {
right: {visible: false},
},

View File

@@ -3,38 +3,21 @@
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');
const mockStore = configureMockStore([thunk]);
const store = mockStore(intitialState);
Store.redux = store;
jest.unmock('app/actions/navigation');
jest.mock('app/store/ephemeral_store', () => ({
getNavigationTopComponentId: jest.fn(),
clearNavigationComponents: jest.fn(),
}));
// Mock EphemeralStore add/remove modal
const add = EphemeralStore.addNavigationModal;
const remove = EphemeralStore.removeNavigationModal;
EphemeralStore.removeNavigationModal = (componentId) => {
remove(componentId);
EphemeralStore.removeNavigationComponentId(componentId);
};
EphemeralStore.addNavigationModal = (componentId) => {
add(componentId);
EphemeralStore.addNavigationComponentId(componentId);
};
describe('@actions/navigation', () => {
describe('app/actions/navigation', () => {
const topComponentId = 'top-component-id';
const name = 'name';
const title = 'title';
@@ -45,16 +28,7 @@ describe('@actions/navigation', () => {
const options = {
testOption: 'test',
};
beforeEach(() => {
EphemeralStore.clearNavigationComponents();
EphemeralStore.clearNavigationModals();
// mock that we have a root screen
EphemeralStore.addNavigationComponentId(topComponentId);
});
// EphemeralStore.getNavigationTopComponentId.mockReturnValue(topComponentId);
EphemeralStore.getNavigationTopComponentId.mockReturnValue(topComponentId);
test('resetToChannel should call Navigation.setRoot', () => {
const setRoot = jest.spyOn(Navigation, 'setRoot');
@@ -79,8 +53,6 @@ describe('@actions/navigation', () => {
height: 0,
backButton: {
visible: false,
enableMenu: false,
color: theme.sidebarHeaderTextColor,
},
background: {
color: theme.sidebarHeaderBg,
@@ -122,7 +94,6 @@ describe('@actions/navigation', () => {
topBar: {
backButton: {
color: theme.sidebarHeaderTextColor,
enableMenu: false,
title: '',
},
background: {
@@ -160,7 +131,6 @@ describe('@actions/navigation', () => {
},
backButton: {
color: theme.sidebarHeaderTextColor,
enableMenu: false,
title: '',
},
background: {
@@ -205,9 +175,7 @@ describe('@actions/navigation', () => {
visible: true,
backButton: {
color: theme.sidebarHeaderTextColor,
enableMenu: false,
title: '',
testID: 'screen.back.button',
},
background: {
color: theme.sidebarHeaderBg,
@@ -254,7 +222,7 @@ describe('@actions/navigation', () => {
const showModal = jest.spyOn(Navigation, 'showModal');
const defaultOptions = {
modalPresentationStyle: Platform.select({ios: 'pageSheet', android: 'none'}),
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
layout: {
componentBackgroundColor: theme.centerChannelBg,
},
@@ -266,7 +234,6 @@ describe('@actions/navigation', () => {
visible: true,
backButton: {
color: theme.sidebarHeaderTextColor,
enableMenu: false,
title: '',
},
background: {
@@ -346,7 +313,6 @@ describe('@actions/navigation', () => {
visible: true,
backButton: {
color: theme.sidebarHeaderTextColor,
enableMenu: false,
title: '',
},
background: {
@@ -393,7 +359,7 @@ describe('@actions/navigation', () => {
},
};
const defaultOptions = {
modalPresentationStyle: Platform.select({ios: 'pageSheet', android: 'none'}),
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
layout: {
componentBackgroundColor: theme.centerChannelBg,
},
@@ -405,7 +371,6 @@ describe('@actions/navigation', () => {
visible: true,
backButton: {
color: theme.sidebarHeaderTextColor,
enableMenu: false,
title: '',
},
background: {
@@ -440,20 +405,15 @@ describe('@actions/navigation', () => {
test('dismissModal should call Navigation.dismissModal', async () => {
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
NavigationActions.showModal('First', 'First Modal', passProps, options);
await NavigationActions.dismissModal(options);
expect(dismissModal).toHaveBeenCalledWith('First', options);
expect(dismissModal).toHaveBeenCalledWith(topComponentId, options);
});
test('dismissAllModals should call Navigation.dismissAllModals', async () => {
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
NavigationActions.showModal('First', 'First Modal', passProps, options);
NavigationActions.showModal('Second', 'Second Modal', passProps, options);
const dismissAllModals = jest.spyOn(Navigation, 'dismissAllModals');
await NavigationActions.dismissAllModals(options);
expect(dismissModal).toHaveBeenCalledTimes(2);
expect(dismissAllModals).toHaveBeenCalledWith(options);
});
test('mergeNavigationOptions should call Navigation.mergeOptions', () => {
@@ -511,18 +471,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 dismissModal = jest.spyOn(Navigation, 'dismissModal');
const popToRoot = jest.spyOn(Navigation, 'popToRoot');
EventEmitter.emit = jest.fn();
NavigationActions.showModal('First', 'First Modal', passProps, options);
NavigationActions.showModal('Second', 'Second Modal', passProps, options);
await NavigationActions.dismissAllModalsAndPopToRoot();
expect(dismissModal).toHaveBeenCalledTimes(2);
expect(popToRoot).toHaveBeenCalledWith(topComponentId);
expect(EventEmitter.emit).toHaveBeenCalledWith(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT);
});
});
});

View File

@@ -5,42 +5,54 @@ import {batchActions} from 'redux-batched-actions';
import {ViewTypes} from 'app/constants';
import {ChannelTypes, RoleTypes, GroupTypes} from '@mm-redux/action_types';
import {UserTypes, ChannelTypes} from 'mattermost-redux/action_types';
import {
fetchMyChannelsAndMembers,
getChannelByName,
joinChannel,
getChannelByNameAndTeamName,
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 {addUserToTeam, getTeamByName, removeUserFromTeam, selectTeam} from '@mm-redux/actions/teams';
import {Client4} from '@client/rest';
import {General, Preferences} from '@mm-redux/constants';
import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
} from 'mattermost-redux/actions/channels';
import {getFilesForPost} from 'mattermost-redux/actions/files';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
import {getTeamMembersByIds, selectTeam} from 'mattermost-redux/actions/teams';
import {getProfilesInChannel} from 'mattermost-redux/actions/users';
import {Client4} from 'mattermost-redux/client';
import {General, Preferences} from 'mattermost-redux/constants';
import {getPostIdsInChannel} from 'mattermost-redux/selectors/entities/posts';
import {
getCurrentChannelId,
getRedirectChannelNameForTeam,
getChannelsNameMapInTeam,
getMyChannelMemberships,
isManuallyUnread,
} from '@mm-redux/selectors/entities/channels';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {getTeamByName as selectTeamByName, getCurrentTeam, getTeamMemberships} from '@mm-redux/selectors/entities/teams';
} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId, getUserIdsInChannels, getUsers} from 'mattermost-redux/selectors/entities/users';
import {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 {
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';
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, {PERF_MARKERS} from '@telemetry';
import {isDirectChannelVisible, isGroupChannelVisible, getChannelSinceValue, privateChannelJoinPrompt} from '@utils/channels';
import {isPendingPost} from '@utils/general';
import {fetchAppBindings} from '@mm-redux/actions/apps';
import {appsEnabled} from '@utils/apps';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from 'app/constants/post_textbox';
import {getChannelReachable} from 'app/selectors/channel';
import telemetry from 'app/telemetry';
import {isDirectChannelVisible, isGroupChannelVisible, isDirectMessageVisible, isGroupMessageVisible, isDirectChannelAutoClosed} from 'app/utils/channels';
import {isPendingPost} from 'app/utils/general';
import {buildPreference} from 'app/utils/preferences';
import {getPosts, getPostsBefore, getPostsSince, getPostThread} from './post';
import {forceLogoutIfNecessary} from './user';
const MAX_RETRIES = 3;
@@ -48,51 +60,158 @@ export function loadChannelsByTeamName(teamName, errorHandler) {
return async (dispatch, getState) => {
const state = getState();
const {currentTeamId} = state.entities.teams;
const team = getTeamByName(state, teamName);
if (teamName) {
const team = selectTeamByName(state, teamName);
if (!team && errorHandler) {
errorHandler();
}
if (!team && errorHandler) {
errorHandler();
return {error: true};
if (team && team.id !== currentTeamId) {
await dispatch(fetchMyChannelsAndMembers(team.id));
}
};
}
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));
}
};
}
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) {
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);
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);
}
postAction = getPostsSince(channelId, since);
}
const received = await dispatch(fetchPostActionWithRetry(postAction));
const received = await retryGetPostsAction(postAction, dispatch, getState);
if (received) {
actions.push({
type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
channelId,
time,
},
setChannelRetryFailed(false));
});
if (received?.order) {
const count = received.order.length;
@@ -101,32 +220,73 @@ export function loadPostsIfNecessaryWithRetry(channelId) {
}
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_RETRIES) {
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));
};
}
@@ -165,7 +325,7 @@ 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;
@@ -187,29 +347,15 @@ export function handleSelectChannel(channelId) {
return async (dispatch, getState) => {
const dt = Date.now();
const state = getState();
const {currentUserId} = state.entities.users;
const {channels, currentChannelId, myMembers} = state.entities.channels;
const {currentTeamId} = state.entities.teams;
const channel = channels[channelId];
const member = myMembers[channelId];
if (channel) {
let markerExtra;
if (channel.display_name) {
markerExtra = `Channel: ${channel.display_name}`;
} else {
markerExtra = `Channel: ${channel.type === General.DM_CHANNEL ? 'Direct Channel' : channel.name}`;
}
dispatch(loadPostsIfNecessaryWithRetry(channelId));
telemetry.start([PERF_MARKERS.CHANNEL_RENDER], Date.now(), [markerExtra]);
dispatch(loadPostsIfNecessaryWithRetry(channelId));
let previousChannelId = null;
if (currentChannelId !== channelId) {
previousChannelId = currentChannelId;
}
const actions = markAsViewedAndReadBatch(state, channelId, previousChannelId);
if (channel && currentChannelId !== channelId) {
const actions = markAsViewedAndReadBatch(state, channelId, currentChannelId);
actions.push({
type: ChannelTypes.SELECT_CHANNEL,
data: channelId,
@@ -219,50 +365,22 @@ export function handleSelectChannel(channelId) {
teamId: channel.team_id || currentTeamId,
},
});
dispatch(batchActions(actions, 'BATCH_SWITCH_CHANNEL'));
if (appsEnabled(state)) {
//TODO improve sync method
dispatch(fetchAppBindings(currentUserId, channelId));
}
console.log('channel switch to', channel?.display_name, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
dispatch(batchActions(actions));
}
return {data: true};
console.log('channel switch to', channel?.display_name, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
};
}
export function handleSelectChannelByName(channelName, teamName, errorHandler, intl) {
export function handleSelectChannelByName(channelName, teamName, errorHandler) {
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 currentUserId = getCurrentUserId(state);
const response = await dispatch(getChannelByNameAndTeamName(teamName || currentTeamName, channelName));
const {error, data: channel} = response;
const currentChannelId = getCurrentChannelId(state);
const {error: teamError, data: team} = await dispatch(getTeamByName(teamName || currentTeamName));
// Fallback to API response error, if any.
if (teamError) {
if (errorHandler) {
errorHandler(intl);
}
return {error: teamError};
}
// Join team if not a member already
const myTeamMemberships = getTeamMemberships(state);
let joinedNewTeam = false;
if (!myTeamMemberships[team.id]) {
await dispatch(addUserToTeam(team.id, currentUserId));
joinedNewTeam = true;
}
const {error: channelError, data: channel} = await dispatch(getChannelByName(team.id, channelName));
state = getState();
const reachable = getChannelReachable(state, channelName, teamName);
if (!reachable && errorHandler) {
@@ -270,35 +388,12 @@ export function handleSelectChannelByName(channelName, teamName, errorHandler, i
}
// Fallback to API response error, if any.
if (channelError) {
return {error: channelError};
}
// Join Channel if not a member already
if (channel && currentChannelId !== channel.id) {
const myChannelMemberships = getMyChannelMemberships(state);
if (!myChannelMemberships[channel.id]) {
if (channel.type === General.PRIVATE_CHANNEL) {
const {join} = await privateChannelJoinPrompt(channel, intl);
if (!join) {
if (joinedNewTeam) {
await dispatch(removeUserFromTeam(team.id, currentUserId));
}
return {data: true};
}
}
console.log('joining channel', channel?.display_name, channel.id); //eslint-disable-line
const result = await dispatch(joinChannel(currentUserId, '', channel.id));
if (result.error || !result.data || !result.data.channel) {
if (joinedNewTeam) {
await dispatch(removeUserFromTeam(team.id, currentUserId));
}
return result;
}
}
if (error) {
return {error};
}
if (teamName && teamName !== currentTeamName) {
const team = getTeamByName(state, teamName);
dispatch(selectTeam(team));
}
@@ -306,21 +401,15 @@ export function handleSelectChannelByName(channelName, teamName, errorHandler, i
dispatch(handleSelectChannel(channel.id));
}
return {data: true};
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 {
type: ViewTypes.POST_DRAFT_CHANGED,
channelId,
draft,
};
}
@@ -336,17 +425,13 @@ 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 {data: true};
return (dispatch) => {
dispatch(markChannelAsRead(channelId, previousChannelId, markOnServer));
dispatch(markChannelAsViewed(channelId, previousChannelId));
};
}
export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', markOnServer = true) {
function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', markOnServer = true) {
const actions = [];
const {channels, myMembers} = state.entities.channels;
const channel = channels[channelId];
@@ -356,9 +441,7 @@ export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', m
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
});
Client4.viewMyChannel(channelId, prevChanManuallyUnread ? '' : prevChannelId);
}
if (member) {
@@ -375,19 +458,12 @@ export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', m
}
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,
amount: channel.total_msg_count - member.msg_count,
},
}, {
type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT,
@@ -436,7 +512,8 @@ export function markChannelViewedAndReadOnReconnect(channelId) {
return;
}
dispatch(markChannelViewedAndRead(channelId));
dispatch(markChannelAsRead(channelId));
dispatch(markChannelAsViewed(channelId));
};
}
@@ -502,16 +579,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;
};
}
@@ -539,6 +610,12 @@ export function leaveChannel(channel, reset = false) {
}
export function setChannelLoading(loading = true) {
if (loading) {
telemetry.start(['channel:loading']);
} else {
telemetry.end(['channel:loading']);
}
return {
type: ViewTypes.SET_CHANNEL_LOADER,
loading,
@@ -587,6 +664,9 @@ export function increasePostVisibility(channelId, postId) {
return true;
}
telemetry.reset();
telemetry.start(['posts:loading']);
dispatch({
type: ViewTypes.LOADING_POSTS,
data: true,
@@ -595,8 +675,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,
@@ -604,10 +683,6 @@ export function increasePostVisibility(channelId, postId) {
channelId,
}];
if (result) {
actions.push(setChannelRetryFailed(false));
}
let hasMorePost = false;
if (result?.order) {
const count = result.order.length;
@@ -616,7 +691,9 @@ export function increasePostVisibility(channelId, postId) {
actions.push(setLoadMorePostsVisible(hasMorePost));
}
dispatch(batchActions(actions, 'BATCH_LOAD_MORE_POSTS'));
dispatch(batchActions(actions));
telemetry.end(['posts:loading']);
telemetry.save();
return hasMorePost;
};
@@ -629,106 +706,18 @@ function setLoadMorePostsVisible(visible) {
};
}
function loadGroupData(isReconnect = false) {
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 getGroupsSince = isReconnect ? (state.websocket?.lastDisconnectAt || 0) : undefined;
const [getAllGroupsAssociatedToChannelsInTeam, getGroups] = await Promise.all([ //eslint-disable-line no-await-in-loop
Client4.getAllGroupsAssociatedToChannelsInTeam(team.id, true),
Client4.getGroups(false, 0, 0, getGroupsSince),
]);
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};
};
}
export function loadChannelsForTeam(teamId, skipDispatch = false, isReconnect = false) {
export function loadChannelsForTeam(teamId) {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const lastConnectAt = state.websocket?.lastConnectAt || 0;
const data = {
sync: true,
teamId,
teamChannels: getChannelsIdForTeam(state, teamId),
};
const actions = [];
if (currentUserId) {
const data = {sync: true, teamId};
for (let i = 0; i <= MAX_RETRIES; i++) {
try {
console.log('Fetching channels attempt', (i + 1), teamId, 'include deleted since', lastConnectAt); //eslint-disable-line no-console
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, lastConnectAt),
Client4.getMyChannels(teamId, true),
Client4.getMyChannelMembers(teamId),
]);
@@ -736,7 +725,8 @@ export function loadChannelsForTeam(teamId, skipDispatch = false, isReconnect =
data.channelMembers = channelMembers;
break;
} catch (err) {
if (i === MAX_RETRIES) {
const result = await dispatch(forceLogoutIfNecessary(err)); //eslint-disable-line no-await-in-loop
if (result || i === MAX_RETRIES) {
const hasChannelsLoaded = state.entities.channels.channelsInTeam[teamId]?.size > 0;
return {error: hasChannelsLoaded ? null : err};
}
@@ -744,73 +734,189 @@ export function loadChannelsForTeam(teamId, skipDispatch = false, isReconnect =
}
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);
}
const roles = new Set();
const members = data.channelMembers;
for (const member of members) {
for (const role of member.roles.split(' ')) {
roles.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'));
if (roles.size > 0) {
dispatch(loadRolesIfNeeded(roles));
}
// Fetch needed profiles from channel creators and direct channels
dispatch(loadSidebar(data));
dispatch(loadSidebarDirectMessagesProfiles(data));
dispatch(loadUnreadChannelPosts(data.channels, data.channelMembers));
dispatch({
type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS,
data,
});
}
dispatch(loadGroupData(isReconnect));
return {data};
}
return {data};
return {error: 'Cannot fetch channels without a current user'};
};
}
export function loadSidebar(data) {
export function loadSidebarDirectMessagesProfiles(data) {
return async (dispatch, getState) => {
const state = getState();
const {channels, channelMembers} = data;
const currentUserId = getCurrentUserId(state);
const usersInChannel = getUserIdsInChannels(state);
const directChannels = Object.values(channels).filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
const prefs = [];
const promises = []; //only fetch profiles that we don't have and the Direct channel should be visible
const userIds = [];
const sidebarActions = await loadSidebarDirectMessagesProfiles(state, channels, channelMembers);
if (sidebarActions.length) {
dispatch(batchActions(sidebarActions, 'BATCH_LOAD_SIDEBAR'));
// Prepare preferences and start fetching profiles to batch them
directChannels.forEach((c) => {
const profilesInChannel = Array.from(usersInChannel[c.id] || []).filter((u) => u.id !== currentUserId);
userIds.push(...profilesInChannel);
switch (c.type) {
case General.DM_CHANNEL: {
const dm = fetchDirectMessageProfileIfNeeded(state, c, channelMembers, profilesInChannel, prefs);
if (dm) {
promises.push(dispatch(dm));
}
break;
}
case General.GM_CHANNEL: {
const gm = fetchGroupMessageProfilesIfNeeded(state, c, channelMembers, profilesInChannel, prefs);
if (gm) {
promises.push(dispatch(gm));
}
break;
}
}
});
// Save preferences if there are any changes
if (prefs.length) {
dispatch(savePreferences(currentUserId, prefs));
}
const actions = [];
const userIdsSet = new Set(userIds);
const profilesAction = await getProfilesFromPromises(promises);
if (profilesAction) {
actions.push(profilesAction);
profilesAction.data.forEach((d) => {
const {users} = d.data;
users.forEach((u) => 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)
}
}
if (actions.length) {
dispatch(batchActions(actions));
}
return {data: true};
};
}
export function resetUnreadMessageCount(channelId) {
return async (dispatch) => {
dispatch({
type: ChannelTypes.SET_UNREAD_MSG_COUNT,
data: {
export function getUsersInChannel(channelId) {
return async (dispatch, getState) => {
try {
const state = getState();
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) => p.id !== currentUserId);
const data = {
channelId,
count: 0,
},
});
users,
};
return {data};
} catch (error) {
return {error};
}
};
}
async function getProfilesFromPromises(promises) {
// Get the profiles returned by the promises
if (!promises.length) {
return null;
}
const result = await Promise.all(promises);
const data = result.filter((p) => !p.error);
return {
type: UserTypes.RECEIVED_BATCHED_PROFILES_IN_CHANNEL,
data,
};
}
function fetchDirectMessageProfileIfNeeded(state, channel, channelMembers, profilesInChannel, newPreferences) {
const currentUserId = getCurrentUserId(state);
const preferences = 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 = isDirectMessageVisible(preferences, channel.id);
const dmAutoClosed = isDirectChannelAutoClosed(config, preferences, channel.id, channel.last_post_at, otherUser?.delete_at, currentChannelId); //eslint-disable-line camelcase
const dmIsUnread = channelMembers[channel.id]?.mention_count > 0; //eslint-disable-line camelcase
const dmFetchProfile = dmIsUnread || (dmVisible && !dmAutoClosed);
// when then DM is hidden but has new messages
if ((!dmVisible || dmAutoClosed) && dmIsUnread) {
newPreferences.push(buildPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, currentUserId, otherUserId));
newPreferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
}
if (dmFetchProfile && !profilesInChannel.includes(otherUserId) && otherUserId !== currentUserId) {
return getUsersInChannel(channel.id);
}
return null;
}
function fetchGroupMessageProfilesIfNeeded(state, channel, channelMembers, profilesInChannel, newPreferences) {
const currentUserId = getCurrentUserId(state);
const preferences = getMyPreferences(state);
const config = getConfig(state);
const gmVisible = isGroupMessageVisible(preferences, channel.id);
const gmAutoClosed = isDirectChannelAutoClosed(config, preferences, channel.id, channel.last_post_at);
const channelMember = channelMembers[channel.id];
const gmIsUnread = channelMember?.mention_count > 0 || channelMember?.msg_count < channel.total_msg_count; //eslint-disable-line camelcase
const gmFetchProfile = gmIsUnread || (gmVisible && !gmAutoClosed);
// when then GM is hidden but has new messages
if ((!gmVisible || gmAutoClosed) && gmIsUnread) {
newPreferences.push(buildPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, currentUserId, channel.id));
newPreferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
}
if (gmFetchProfile && !profilesInChannel.length) {
return getUsersInChannel(channel.id);
}
return null;
}

View File

@@ -4,26 +4,24 @@
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import initialState from 'app/initial_state';
import {ChannelTypes} from 'mattermost-redux/action_types';
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 {General} from '@mm-redux/constants';
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'}),
@@ -31,33 +29,11 @@ jest.mock('@mm-redux/actions/channels', () => {
};
});
jest.mock('@mm-redux/actions/teams', () => {
const teamActions = jest.requireActual('../../mm-redux/actions/teams');
return {
...teamActions,
getTeamByName: jest.fn((teamName) => {
if (teamName) {
return {
type: 'MOCK_RECEIVE_TEAM_TYPE',
data: {
id: 'current-team-id',
name: 'received-team-id',
},
};
}
return {
type: 'MOCK_ERROR',
error: 'error',
};
}),
};
});
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,
selectTeamByName: jest.fn(() => ({name: 'current-team-name'})),
getTeamByName: jest.fn(() => ({name: 'current-team-name'})),
};
});
@@ -72,10 +48,9 @@ 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');
actions.getChannelByName = jest.fn((teamId, channelName) => {
if (teamId && channelName) {
const actions = require('mattermost-redux/actions/channels');
actions.getChannelByNameAndTeamName = jest.fn((teamName) => {
if (teamName) {
return {
type: MOCK_RECEIVE_CHANNEL_TYPE,
data: 'received-channel-id',
@@ -91,10 +66,6 @@ describe('Actions.Views.Channel', () => {
type: MOCK_SELECT_CHANNEL_TYPE,
data: 'selected-channel-id',
});
actions.joinChannel = jest.fn((userId, teamId, channelId) => ({
type: 'MOCK_JOIN_CHANNEL',
data: {channel: {id: channelId}},
}));
const postActions = require('./post');
postActions.getPostsSince = jest.fn(() => {
return {
@@ -125,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;
});
@@ -163,20 +134,16 @@ describe('Actions.Views.Channel', () => {
name: currentTeamName,
},
},
myMembers: {
[currentTeamId]: {},
},
},
},
};
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');
const getChannelReachableOriginal = appChannelSelectors.getChannelReachable;
appChannelSelectors.getChannelReachable = jest.fn(() => true);
test('handleSelectChannelByName success', async () => {
@@ -209,7 +176,7 @@ describe('Actions.Views.Channel', () => {
test('handleSelectChannelByName failure from no permission to channel', async () => {
store = mockStore({...storeObj});
actions.getChannelByName = jest.fn(() => {
actions.getChannelByNameAndTeamName = jest.fn(() => {
return {
type: 'MOCK_ERROR',
error: {
@@ -237,82 +204,6 @@ describe('Actions.Views.Channel', () => {
expect(receivedChannel).toBe(false);
});
test('handleSelectChannelByName select channel that user is not a member of', async () => {
actions.getChannelByName = jest.fn(() => {
return {
type: MOCK_RECEIVE_CHANNEL_TYPE,
data: {id: 'channel-id-3', name: 'channel-id-3', display_name: 'Test Channel', type: General.OPEN_CHANNEL},
};
});
store = mockStore(storeObj);
await store.dispatch(handleSelectChannelByName('channel-id-3', currentTeamName));
const storeActions = store.getActions();
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
expect(receivedChannel).toBe(true);
expect(actions.joinChannel).toBeCalled();
const joinedChannel = storeActions.some((action) => action.type === 'MOCK_JOIN_CHANNEL' && action.data.channel.id === 'channel-id-3');
expect(joinedChannel).toBe(true);
});
test('handleSelectChannelByName select archived channel with ExperimentalViewArchivedChannels enabled', async () => {
const archivedChannelStoreObj = {...storeObj};
archivedChannelStoreObj.entities.general.config.ExperimentalViewArchivedChannels = 'true';
store = mockStore(archivedChannelStoreObj);
appChannelSelectors.getChannelReachable = getChannelReachableOriginal;
actions.getChannelByName = jest.fn(() => {
return {
type: MOCK_RECEIVE_CHANNEL_TYPE,
data: {id: 'channel-id-3', name: 'channel-id-3', display_name: 'Test Channel', type: General.OPEN_CHANNEL, delete_at: 100},
};
});
channelSelectors.getChannelByName = jest.fn(() => {
return {
data: {id: 'channel-id-3', name: 'channel-id-3', display_name: 'Test Channel', type: General.OPEN_CHANNEL, delete_at: 100},
};
});
const errorHandler = jest.fn();
await store.dispatch(handleSelectChannelByName('channel-id-3', currentTeamName, errorHandler));
const storeActions = store.getActions();
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
expect(receivedChannel).toBe(true);
expect(errorHandler).not.toBeCalled();
});
test('handleSelectChannelByName select archived channel with ExperimentalViewArchivedChannels disabled', async () => {
const noArchivedChannelStoreObj = {...storeObj};
noArchivedChannelStoreObj.entities.general.config.ExperimentalViewArchivedChannels = 'false';
store = mockStore(noArchivedChannelStoreObj);
appChannelSelectors.getChannelReachable = getChannelReachableOriginal;
actions.getChannelByName = jest.fn(() => {
return {
type: MOCK_RECEIVE_CHANNEL_TYPE,
data: {id: 'channel-id-3', name: 'channel-id-3', display_name: 'Test Channel', type: General.OPEN_CHANNEL, delete_at: 100},
};
});
channelSelectors.getChannelByName = jest.fn(() => {
return {
data: {id: 'channel-id-3', name: 'channel-id-3', display_name: 'Test Channel', type: General.OPEN_CHANNEL, delete_at: 100},
};
});
const errorHandler = jest.fn();
await store.dispatch(handleSelectChannelByName('channel-id-3', currentTeamName, errorHandler));
const storeActions = store.getActions();
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
expect(receivedChannel).toBe(true);
expect(errorHandler).toBeCalled();
});
test('loadPostsIfNecessaryWithRetry for the first time', async () => {
store = mockStore(storeObj);
@@ -320,9 +211,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, {
@@ -406,7 +297,7 @@ describe('Actions.Views.Channel', () => {
await store.dispatch(handleSelectChannel(channelId));
const storeActions = store.getActions();
const storeBatchActions = storeActions.find(({type}) => type === 'BATCH_SWITCH_CHANNEL');
const storeBatchActions = storeActions.find(({type}) => type === 'BATCHING_REDUCER.BATCH');
const selectChannelWithMember = storeBatchActions?.payload.find(({type}) => type === ChannelTypes.SELECT_CHANNEL);
const viewedAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_VIEWED);
const readAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_READ);
@@ -428,7 +319,7 @@ describe('Actions.Views.Channel', () => {
teamId: currentTeamId,
},
};
if (channelId.includes('not')) {
if (channelId.includes('not') || channelId === currentChannelId) {
expect(selectChannelWithMember).toBe(undefined);
} else {
expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember);

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ViewTypes} from 'app/constants';
export function setLastUpgradeCheck() {
return {
type: ViewTypes.SET_LAST_UPGRADE_CHECK,
};
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
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) => {
const state = getState();
const teamId = getCurrentTeamId(state);
const args = {
channel_id: channelId,
team_id: teamId,
root_id: rootId,
parent_id: rootId,
};
let msg = message;
let cmdLength = msg.indexOf(' ');
if (cmdLength < 0) {
cmdLength = msg.length;
}
const cmd = msg.substring(0, cmdLength).toLowerCase();
msg = cmd + msg.substring(cmdLength, msg.length);
const {data, error} = await dispatch(executeCommandService(msg, args));
if (data?.trigger_id) { //eslint-disable-line camelcase
dispatch({type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id});
}
return {data, error};
};
}

View File

@@ -1,97 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {intlShape} from 'react-intl';
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 {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import {DispatchFunc, GetStateFunc, ActionFunc} from '@mm-redux/types/actions';
import {CommandArgs} from '@mm-redux/types/integrations';
import {AppCommandParser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser';
import {doAppCall, postEphemeralCallResponseForCommandArgs} from '@actions/apps';
import {appsEnabled} from '@utils/apps';
import {AppCallResponse} from '@mm-redux/types/apps';
export function executeCommand(message: string, channelId: string, rootId: string, intl: typeof intlShape): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState();
const teamId = getCurrentTeamId(state);
const args: CommandArgs = {
channel_id: channelId,
team_id: teamId,
root_id: rootId,
parent_id: rootId,
};
let msg = message;
msg = filterEmDashForCommand(msg);
let cmdLength = msg.indexOf(' ');
if (cmdLength < 0) {
cmdLength = msg.length;
}
const cmd = msg.substring(0, cmdLength).toLowerCase();
msg = cmd + msg.substring(cmdLength, msg.length);
const appsAreEnabled = appsEnabled(state);
if (appsAreEnabled) {
const parser = new AppCommandParser({dispatch, getState}, intl, args.channel_id, args.root_id);
if (parser.isAppCommand(msg)) {
const {call, errorMessage} = await parser.composeCallFromCommand(msg);
const createErrorMessage = (errMessage: string) => {
return {error: {message: errMessage}};
};
if (!call) {
return createErrorMessage(errorMessage!);
}
const res = await dispatch(doAppCall(call, AppCallTypes.SUBMIT, intl));
if (res.error) {
const errorResponse = res.error as AppCallResponse;
return createErrorMessage(errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
}));
}
const callResp = res.data as AppCallResponse;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.markdown, args));
}
return {data: {}};
case AppCallResponseTypes.FORM:
case AppCallResponseTypes.NAVIGATE:
return {data: {}};
default:
return createErrorMessage(intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
}));
}
}
}
const {data, error} = await dispatch(executeCommandService(msg, args));
if (data?.trigger_id) { //eslint-disable-line camelcase
dispatch({type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id});
}
return {data, error};
};
}
const filterEmDashForCommand = (command: string): string => {
return command.replace(/\u2014/g, '--');
};

View File

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

View File

@@ -1,66 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@client/rest';
import {logError} from '@mm-redux/actions/errors';
import {UserTypes} from '@mm-redux/action_types';
import {getCurrentUser} from '@mm-redux/selectors/entities/common';
import {ActionFunc, DispatchFunc, batchActions, GetStateFunc} from '@mm-redux/types/actions';
import {UserCustomStatus} from '@mm-redux/types/users';
export function setCustomStatus(customStatus: UserCustomStatus): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const user = getCurrentUser(getState());
if (!user.props) {
user.props = {};
}
const oldCustomStatus = user.props.customStatus;
user.props.customStatus = JSON.stringify(customStatus);
dispatch({type: UserTypes.RECEIVED_ME, data: user});
try {
await Client4.updateCustomStatus(customStatus);
} catch (error) {
user.props.customStatus = oldCustomStatus;
dispatch(batchActions([
{type: UserTypes.RECEIVED_ME, data: user},
logError(error),
]));
return {error};
}
return {data: true};
};
}
export function unsetCustomStatus(): ActionFunc {
return async (dispatch: DispatchFunc) => {
try {
await Client4.unsetCustomStatus();
} catch (error) {
dispatch(logError(error));
return {error};
}
return {data: true};
};
}
export function removeRecentCustomStatus(customStatus: UserCustomStatus): ActionFunc {
return async (dispatch: DispatchFunc) => {
try {
await Client4.removeRecentCustomStatus(customStatus);
} catch (error) {
dispatch(logError(error));
return {error};
}
return {data: true};
};
}
export default {
setCustomStatus,
unsetCustomStatus,
removeRecentCustomStatus,
};

View File

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

View File

@@ -3,14 +3,12 @@
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 '@client/rest';
import {getPostIdsInCurrentChannel, makeGetPostIdsForThread} from '@mm-redux/selectors/entities/posts';
import {EmojiTypes} from 'mattermost-redux/action_types';
import {addReaction as serviceAddReaction, getNeededCustomEmojis} from 'mattermost-redux/actions/posts';
import {Client4} from 'mattermost-redux/client';
import {getPostIdsInCurrentChannel, makeGetPostIdsForThread} from 'mattermost-redux/selectors/entities/posts';
import {ViewTypes} from 'app/constants';
import {EmojiIndicesByAlias, EmojiIndicesByUnicode, Emojis} from '@utils/emojis';
import emojiRegex from 'emoji-regex';
const getPostIdsForThread = makeGetPostIdsForThread();
@@ -57,7 +55,7 @@ export function getEmojisInPosts(posts) {
const emojisToLoad = getNeededCustomEmojis(state, posts);
if (emojisToLoad?.size > 0) {
const promises = Array.from(emojisToLoad).map((name) => getCustomEmojiByName(name));
const promises = emojisToLoad.map((name) => getCustomEmojiByName(name));
const result = await Promise.all(promises);
const actions = [];
const data = [];
@@ -81,7 +79,7 @@ export function getEmojisInPosts(posts) {
}
if (actions.length) {
dispatch(batchActions(actions, 'BATCH_GET_EMOJIS_FOR_POSTS'));
dispatch(batchActions(actions));
}
}
};
@@ -99,42 +97,4 @@ async function getCustomEmojiByName(name) {
}
return null;
}
export function addRecentUsedEmojisInMessage(message) {
return (dispatch) => {
const RE_UNICODE_EMOJI = emojiRegex();
const RE_NAMED_EMOJI = /(:([a-zA-Z0-9_-]+):)/g;
const emojis = message.match(RE_UNICODE_EMOJI);
const namedEmojis = message.match(RE_NAMED_EMOJI);
function emojiUnicode(input) {
const emoji = [];
for (const i of input) {
emoji.push(i.codePointAt(0).toString(16));
}
return emoji.join('-');
}
const emojisAvailableWithMattermost = [];
if (emojis) {
for (const emoji of emojis) {
const unicode = emojiUnicode(emoji);
const index = EmojiIndicesByUnicode.get(unicode || '');
if (index) {
emojisAvailableWithMattermost.push(Emojis[index].aliases[0]);
}
}
}
if (namedEmojis) {
for (const emoji of namedEmojis) {
const index = EmojiIndicesByAlias.get(emoji.slice(1, -1));
if (index) {
emojisAvailableWithMattermost.push(Emojis[index].aliases[0]);
}
}
}
dispatch({
type: ViewTypes.ADD_RECENT_EMOJI_ARRAY,
emojis: emojisAvailableWithMattermost,
});
};
}
}

View File

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

View File

@@ -3,21 +3,36 @@
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 '@client/rest';
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 '@init/push_notifications';
import {getDeviceTimezone} from 'app/utils/timezone';
import PushNotifications from 'app/push_notifications';
import {getDeviceTimezoneAsync} from 'app/utils/timezone';
import {setCSRFFromCookie} from 'app/utils/security';
import {loadConfigAndLicense} from 'app/actions/views/root';
export function handleLoginIdChanged(loginId) {
return {
type: ViewTypes.LOGIN_ID_CHANGED,
loginId,
};
}
export function handlePasswordChanged(password) {
return {
type: ViewTypes.PASSWORD_CHANGED,
password,
};
}
export function handleSuccessfulLogin() {
return async (dispatch, getState) => {
await dispatch(loadConfigAndLicense());
@@ -35,7 +50,7 @@ export function handleSuccessfulLogin() {
const enableTimezone = isTimezoneEnabled(state);
if (enableTimezone) {
const timezone = getDeviceTimezone();
const timezone = await getDeviceTimezoneAsync();
dispatch(autoUpdateTimezone(timezone));
}
@@ -94,11 +109,11 @@ export function scheduleExpiredNotification(intl) {
});
if (expiresAt) {
PushNotifications.scheduleNotification({
fireDate: expiresAt,
body: message,
PushNotifications.localNotificationSchedule({
date: new Date(expiresAt),
message,
userInfo: {
local: true,
localNotification: true,
},
});
}
@@ -107,6 +122,8 @@ export function scheduleExpiredNotification(intl) {
}
export default {
handleLoginIdChanged,
handlePasswordChanged,
handleSuccessfulLogin,
scheduleExpiredNotification,
};

View File

@@ -4,14 +4,35 @@
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {Client4} from '@client/rest';
import * as GeneralActions from 'mattermost-redux/actions/general';
import {handleSuccessfulLogin} from 'app/actions/views/login';
import {ViewTypes} from 'app/constants';
import {
handleLoginIdChanged,
handlePasswordChanged,
handleSuccessfulLogin,
} 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', () => {
@@ -30,9 +51,31 @@ describe('Actions.Views.Login', () => {
});
});
test('handleLoginIdChanged', () => {
const loginId = 'email@example.com';
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]);
});
test('handleSuccessfulLogin gets config and license ', async () => {
const getClientConfig = jest.spyOn(Client4, 'getClientConfigOld');
const getLicenseConfig = jest.spyOn(Client4, 'getClientLicenseOld');
const getClientConfig = jest.spyOn(GeneralActions, 'getClientConfig');
const getLicenseConfig = jest.spyOn(GeneralActions, 'getLicenseConfig');
await store.dispatch(handleSuccessfulLogin());
expect(getClientConfig).toHaveBeenCalled();

View File

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

View File

@@ -1,59 +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 {dismissAllModals, showModalOverCurrentContext} from '@actions/navigation';
import {loadChannelsByTeamName} from '@actions/views/channel';
import {selectFocusedPostId} from '@mm-redux/actions/posts';
import {getCurrentTeam} from '@mm-redux/selectors/entities/teams';
import {permalinkBadTeam} from '@utils/general';
import {changeOpacity} from '@utils/theme';
import type {DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
let showingPermalink = false;
export function showPermalink(intl: typeof intlShape, teamName: string, postId: string, openAsPermalink = true) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
let name = teamName;
if (!name) {
name = getCurrentTeam(getState()).name;
}
const loadTeam = await dispatch(loadChannelsByTeamName(name, permalinkBadTeam.bind(null, intl)));
if (!loadTeam.error) {
Keyboard.dismiss();
dispatch(selectFocusedPostId(postId));
if (showingPermalink) {
await dismissAllModals();
}
const screen = 'Permalink';
const passProps = {
isPermalink: openAsPermalink,
teamName,
};
const options = {
layout: {
componentBackgroundColor: changeOpacity('#000', 0.2),
},
};
showingPermalink = true;
showModalOverCurrentContext(screen, passProps, options);
}
return {};
};
}
export function closePermalink() {
return async (dispatch: DispatchFunc) => {
showingPermalink = false;
return dispatch(selectFocusedPostId(''));
};
}

View File

@@ -3,53 +3,26 @@
import {batchActions} from 'redux-batched-actions';
import {UserTypes} from '@mm-redux/action_types';
import {UserTypes} from 'mattermost-redux/action_types';
import {
doPostAction,
getNeededAtMentionedUsernames,
receivedNewPost,
receivedPost,
receivedPosts,
receivedPostsBefore,
receivedPostsInChannel,
receivedPostsSince,
receivedPostsInThread,
} from '@mm-redux/actions/posts';
import {Client4} from '@client/rest';
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';
} from 'mattermost-redux/actions/posts';
import {Client4} from 'mattermost-redux/client';
import {Posts} from 'mattermost-redux/constants';
import {removeUserFromList} from 'mattermost-redux/utils/user_utils';
import {ViewTypes} from '@constants';
import {generateId} from '@utils/file';
import {getChannelSinceValue} from '@utils/channels';
import {ViewTypes} from 'app/constants';
import {generateId} from 'app/utils/file';
import {getEmojisInPosts} from './emoji';
export function sendEphemeralPost(message, channelId = '', parentId = '', userId = '0') {
return async (dispatch, getState) => {
const timestamp = Date.now();
const post = {
id: generateId(),
user_id: userId,
channel_id: channelId || getCurrentChannelId(getState()),
message,
type: Posts.POST_TYPES.EPHEMERAL,
create_at: timestamp,
update_at: timestamp,
root_id: parentId,
parent_id: parentId,
props: {},
};
dispatch(receivedNewPost(post));
return {};
};
}
export function sendAddToChannelEphemeralPost(user, addedUsername, message, channelId, postRootId = '') {
return async (dispatch) => {
const timestamp = Date.now();
@@ -73,14 +46,13 @@ export function sendAddToChannelEphemeralPost(user, addedUsername, message, chan
};
}
export function setAutocompleteSelector(dataSource, onSelect, options, getDynamicOptions) {
export function setAutocompleteSelector(dataSource, onSelect, options) {
return {
type: ViewTypes.SELECTED_ACTION_MENU,
data: {
dataSource,
onSelect,
options,
getDynamicOptions,
},
};
}
@@ -110,16 +82,13 @@ export function getPosts(channelId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
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,
}];
const actions = [];
if (posts?.length) {
actions.push(receivedPosts(data));
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.data.length) {
actions.push(...additional.data);
if (additional.length) {
actions.push(...additional);
}
}
@@ -127,32 +96,7 @@ export function getPosts(channelId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
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'));
}
dispatch(batchActions(actions));
return {data};
} catch (error) {
@@ -174,11 +118,11 @@ export function getPostsSince(channelId, since) {
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.data.length) {
actions.push(...additional.data);
if (additional.length) {
actions.push(...additional);
}
dispatch(batchActions(actions, 'BATCH_GET_POSTS_SINCE'));
dispatch(batchActions(actions));
}
return {data};
@@ -201,11 +145,11 @@ export function getPostsBefore(channelId, postId, page = 0, perPage = Posts.POST
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.data.length) {
actions.push(...additional.data);
if (additional.length) {
actions.push(...additional);
}
dispatch(batchActions(actions, 'BATCH_GET_POSTS_BEFORE'));
dispatch(batchActions(actions));
}
return {data};
@@ -215,7 +159,7 @@ export function getPostsBefore(channelId, postId, page = 0, perPage = Posts.POST
};
}
export function getPostThread(rootId, skipDispatch = false) {
export function getPostThread(rootId) {
return async (dispatch) => {
try {
const data = await Client4.getPostThread(rootId);
@@ -228,15 +172,11 @@ export function getPostThread(rootId, skipDispatch = false) {
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.data.length) {
actions.push(...additional.data);
if (additional.length) {
actions.push(...additional);
}
if (skipDispatch) {
return {data: actions};
}
dispatch(batchActions(actions, 'BATCH_GET_POSTS_THREAD'));
dispatch(batchActions(actions));
}
return {data};
@@ -279,11 +219,11 @@ export function getPostsAround(channelId, postId, perPage = Posts.POST_CHUNK_SIZ
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.data.length) {
actions.push(...additional.data);
if (additional.length) {
actions.push(...additional);
}
dispatch(batchActions(actions, 'BATCH_GET_POSTS_AROUND'));
dispatch(batchActions(actions));
}
return {data};
@@ -293,40 +233,12 @@ export function getPostsAround(channelId, postId, perPage = Posts.POST_CHUNK_SIZ
};
}
export function handleNewPostBatch(WebSocketMessage) {
function getPostsAdditionalDataBatch(posts = []) {
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 = [];
const actions = [];
if (!posts.length) {
return {data};
return actions;
}
// Custom Emojis used in the posts
@@ -337,7 +249,7 @@ export function getPostsAdditionalDataBatch(posts = []) {
const state = getState();
const promises = [];
const promiseTrace = [];
const extra = userMetadataToLoadFromPosts(state, posts);
const extra = dispatch(profilesStatusesAndToLoadFromPosts(posts));
if (extra?.userIds.length) {
promises.push(Client4.getProfilesByIds(extra.userIds));
@@ -361,7 +273,7 @@ export function getPostsAdditionalDataBatch(posts = []) {
const type = promiseTrace[index];
switch (type) {
case 'statuses':
data.push({
actions.push({
type: UserTypes.RECEIVED_STATUSES,
data: p,
});
@@ -370,7 +282,7 @@ export function getPostsAdditionalDataBatch(posts = []) {
const {currentUserId} = state.entities.users;
removeUserFromList(currentUserId, p);
data.push({
actions.push({
type: UserTypes.RECEIVED_PROFILES_LIST,
data: p,
});
@@ -384,123 +296,42 @@ export function getPostsAdditionalDataBatch(posts = []) {
// do nothing
}
return {data};
return actions;
};
}
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) => {
function profilesStatusesAndToLoadFromPosts(posts = []) {
return (dispatch, getState) => {
const state = getState();
const currentChannelId = getCurrentChannelId(state);
const {currentUserId, profiles, statuses} = state.entities.users;
const promises = [];
const promiseTrace = [];
// Profiles of users mentioned in the posts
const usernamesToLoad = getNeededAtMentionedUsernames(state, posts);
const channelMembersByChannel = {};
channelMembers.forEach((member) => {
channelMembersByChannel[member.channel_id] = member;
});
// Statuses and profiles of the users who made the posts
const userIdsToLoad = new Set();
const statusesToLoad = new Set();
channels.forEach((channel) => {
if (channel.id === currentChannelId || isArchivedChannel(channel)) {
posts.forEach((post) => {
const userId = post.user_id;
if (!statuses[userId]) {
statusesToLoad.add(userId);
}
if (userId === currentUserId) {
return;
}
const isUnread = isUnreadChannel(channelMembersByChannel, channel);
if (!isUnread) {
return;
if (!profiles[userId]) {
userIdsToLoad.add(userId);
}
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));
}
return {
usernames: Array.from(usernamesToLoad),
userIds: Array.from(userIdsToLoad),
statuses: Array.from(statusesToLoad),
};
};
}
}

View File

@@ -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 '@client/rest';
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);
});
});

View File

@@ -3,19 +3,19 @@
import {batchActions} from 'redux-batched-actions';
import {NavigationTypes, ViewTypes} from '@constants';
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 '@client/rest';
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 {ChannelTypes, GeneralTypes, TeamTypes} 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} from 'mattermost-redux/actions/teams';
import {markAsViewedAndReadBatch} from './channel';
import {ViewTypes} from 'app/constants';
import EphemeralStore from 'app/store/ephemeral_store';
import {recordTime} from 'app/utils/segment';
import {markChannelViewedAndRead} from './channel';
export function startDataCleanup() {
return async (dispatch, getState) => {
@@ -29,53 +29,41 @@ 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') {
dispatch(getDataRetentionPolicy());
} 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};
};
}
export function loadFromPushNotification(notification) {
return async (dispatch, getState) => {
const state = getState();
const {payload} = notification;
const {data} = notification;
const {currentTeamId, teams, myMembers: myTeamMembers} = state.entities.teams;
const {channels} = state.entities.channels;
let channelId = '';
let teamId = currentTeamId;
if (payload) {
channelId = payload.channel_id;
if (data) {
channelId = data.channel_id;
// when the notification does not have a team id is because its from a DM or GM
teamId = payload.team_id || currentTeamId;
teamId = data.team_id || currentTeamId;
}
// load any missing data
@@ -95,22 +83,18 @@ export function loadFromPushNotification(notification) {
}
dispatch(handleSelectTeamAndChannel(teamId, channelId));
return {data: true};
};
}
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);
const actions = [];
// when the notification is from a team other than the current team
if (teamId !== currentTeamId) {
@@ -127,31 +111,25 @@ export function handleSelectTeamAndChannel(teamId, channelId) {
teamId: channel.team_id || currentTeamId,
},
});
dispatch(markChannelViewedAndRead(channelId));
}
if (actions.length) {
dispatch(batchActions(actions, 'BATCH_SELECT_TEAM_AND_CHANNEL'));
dispatch(batchActions(actions));
}
// eslint-disable-next-line no-console
console.log('channel switch from push notification to', channel?.display_name || channel?.id, (Date.now() - dt), 'ms');
EphemeralStore.setStartFromNotification(false);
console.log('channel switch from push notification to', channel?.display_name, (Date.now() - dt), 'ms'); //eslint-disable-line
};
}
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) => {
@@ -179,6 +157,14 @@ export function createPostForNotificationReply(post) {
};
}
export function recordLoadTime(screenName, category) {
return async (dispatch, getState) => {
const {currentUserId} = getState().entities.users;
recordTime(screenName, category, currentUserId);
};
}
export function setDeepLinkURL(url) {
return {
type: ViewTypes.SET_DEEP_LINK_URL,

View File

@@ -2,7 +2,7 @@
// 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';
@@ -11,7 +11,7 @@ export function handleServerUrlChanged(serverUrl) {
{type: GeneralTypes.CLIENT_CONFIG_RESET},
{type: GeneralTypes.CLIENT_LICENSE_RESET},
{type: ViewTypes.SERVER_URL_CHANGED, serverUrl},
], 'BATCH_SERVER_URL_CHANGED');
]);
}
export function setServerUrl(serverUrl) {

View File

@@ -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';
@@ -26,7 +26,7 @@ describe('Actions.Views.SelectServer', () => {
{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]);

View File

@@ -3,16 +3,14 @@
import {batchActions} from 'redux-batched-actions';
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 {Preferences, RequestStatus} from '@mm-redux/constants';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {get as getPreference} from '@mm-redux/selectors/entities/preferences';
import {getCurrentLocale} from 'app/selectors/i18n';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {selectFirstAvailableTeam} from '@utils/teams';
import {ChannelTypes, 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 {NavigationTypes} from 'app/constants';
import {selectFirstAvailableTeam} from 'app/utils/teams';
export function handleTeamChange(teamId) {
return async (dispatch, getState) => {
@@ -22,28 +20,10 @@ 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(batchActions([
{type: TeamTypes.SELECT_TEAM, data: teamId},
{type: ChannelTypes.SELECT_CHANNEL, data: '', extra: {}},
]));
};
}
@@ -52,18 +32,10 @@ export function selectDefaultTeam() {
const state = getState();
const {ExperimentalPrimaryTeam} = getConfig(state);
const locale = getCurrentLocale(state);
const userTeamOrderPreference = getPreference(state, Preferences.TEAMS_ORDER, '', '');
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, locale, userTeamOrderPreference, ExperimentalPrimaryTeam);
let defaultTeam = selectFirstAvailableTeam(teams, ExperimentalPrimaryTeam);
if (defaultTeam) {
dispatch(handleTeamChange(defaultTeam.id));
@@ -79,7 +51,7 @@ export function selectDefaultTeam() {
}
if (data) {
defaultTeam = selectFirstAvailableTeam(data, locale, userTeamOrderPreference, ExperimentalPrimaryTeam);
defaultTeam = selectFirstAvailableTeam(data, ExperimentalPrimaryTeam);
}
if (defaultTeam) {

View File

@@ -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 '@client/rest';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import PushNotifications from '@init/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.scheduleNotification({
fireDate: expiresAt,
body: message,
userInfo: {
local: true,
},
});
}
};
}

View File

@@ -4,16 +4,10 @@
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 {
type: ViewTypes.COMMENT_DRAFT_CHANGED,
rootId,
draft,
};
}

View File

@@ -17,13 +17,7 @@ describe('Actions.Views.Thread', () => {
let store;
beforeEach(() => {
store = mockStore({
views: {
thread: {
drafts: {},
},
},
});
store = mockStore({});
});
test('handleCommentDraftChanged', () => {

View File

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

View File

@@ -1,24 +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 '@client/rest';
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 {GeneralTypes, UserTypes} from 'mattermost-redux/action_types';
import {getDataRetentionPolicy} from 'mattermost-redux/actions/general';
import * as HelperActions from 'mattermost-redux/actions/helpers';
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone';
import {Client4} from 'mattermost-redux/client';
import {General} from 'mattermost-redux/constants';
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 {setAppCredentials} from 'app/init/credentials';
import {setCSRFFromCookie} from '@utils/security';
import {getDeviceTimezone} from '@utils/timezone';
import {analytics} from '@init/analytics.ts';
import {setCSRFFromCookie} from 'app/utils/security';
import {getDeviceTimezoneAsync} from 'app/utils/timezone';
const HTTP_UNAUTHORIZED = 401;
@@ -35,13 +31,13 @@ export function completeLogin(user, deviceToken) {
// Set timezone
const enableTimezone = isTimezoneEnabled(state);
if (enableTimezone) {
const timezone = getDeviceTimezone();
const timezone = await getDeviceTimezoneAsync();
dispatch(autoUpdateTimezone(timezone));
}
// Data retention
if (config?.DataRetentionEnableMessageDeletion && config?.DataRetentionEnableMessageDeletion === 'true' &&
license?.IsLicensed === 'true' && license?.DataRetention === 'true') {
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
license.IsLicensed === 'true' && license.DataRetention === 'true') {
dispatch(getDataRetentionPolicy());
} else {
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
@@ -49,42 +45,14 @@ export function completeLogin(user, deviceToken) {
};
}
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) {
export function loadMe(user, deviceToken) {
return async (dispatch, getState) => {
const state = getState();
const data = {user};
const deviceId = state.entities?.general?.deviceToken;
try {
if (deviceId && !deviceToken && !skipDispatch) {
if (deviceId && !deviceToken) {
await Client4.attachDevice(deviceId);
}
@@ -97,8 +65,8 @@ export function loadMe(user, deviceToken, skipDispatch = false) {
}
try {
analytics.setUserId(data.user.id);
analytics.setUserRoles(data.user.roles);
Client4.setUserId(data.user.id);
Client4.setUserRoles(data.user.roles);
// Execute all other requests in parallel
const teamsRequest = Client4.getMyTeams();
@@ -106,7 +74,6 @@ export function loadMe(user, deviceToken, skipDispatch = false) {
const teamUnreadRequest = Client4.getMyTeamUnreads();
const preferencesRequest = Client4.getMyPreferences();
const configRequest = Client4.getClientConfigOld();
const actions = [];
const [teams, teamMembers, teamUnreads, preferences, config] = await Promise.all([
teamsRequest,
@@ -123,33 +90,22 @@ export function loadMe(user, deviceToken, skipDispatch = false) {
data.config = config;
data.url = Client4.getUrl();
actions.push({
dispatch({
type: UserTypes.LOGIN,
data,
});
const rolesToLoad = new Set();
const roles = new Set();
for (const role of data.user.roles.split(' ')) {
rolesToLoad.add(role);
roles.add(role);
}
for (const teamMember of teamMembers) {
for (const role of teamMember.roles.split(' ')) {
rolesToLoad.add(role);
roles.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'));
if (roles.size > 0) {
dispatch(loadRolesIfNeeded(roles));
}
} catch (error) {
console.log('login error', error.stack); // eslint-disable-line no-console
@@ -183,14 +139,14 @@ export function login(loginId, password, mfaToken, ldapOnly = false) {
};
}
export function ssoLogin() {
return async (dispatch, getState) => {
const state = getState();
const deviceToken = state.entities?.general?.deviceToken;
export function ssoLogin(token) {
return async (dispatch) => {
Client4.setToken(token);
await setCSRFFromCookie(Client4.getUrl());
const result = await dispatch(loadMe());
if (!result.error) {
dispatch(completeLogin(result.data.user, deviceToken));
dispatch(completeLogin(result.data.user));
}
return result;
@@ -198,7 +154,7 @@ export function ssoLogin() {
}
export function logout(skipServerLogout = false) {
return async () => {
return async (dispatch) => {
if (!skipServerLogout) {
try {
Client4.logout();
@@ -207,8 +163,7 @@ export function logout(skipServerLogout = false) {
}
}
EventEmitter.emit(NavigationTypes.NAVIGATION_RESET);
return {data: true};
dispatch({type: UserTypes.LOGOUT_SUCCESS});
};
}
@@ -225,21 +180,16 @@ export function forceLogoutIfNecessary(error) {
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;
HelperActions.forceLogoutIfNecessary = forceLogoutIfNecessary;

View File

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

View File

@@ -1,18 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchAppBindings} from '@mm-redux/actions/apps';
import {getCurrentChannelId} from '@mm-redux/selectors/entities/common';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
import {appsEnabled} from '@utils/apps';
export function handleRefreshAppsBindings() {
return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => {
const state = getState();
if (appsEnabled(state)) {
dispatch(fetchAppBindings(getCurrentUserId(state), getCurrentChannelId(state)));
}
return {data: true};
};
}

View File

@@ -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 '@client/rest';
import {General} from '@mm-redux/constants';
import * as Actions from '@actions/websocket';
import {WebsocketEvents} from '@constants';
import globalInitialState from '@store/initial_state';
import TestHelper from 'test/test_helper';
import configureStore from 'test/test_store';
global.WebSocket = MockWebSocket;
describe('Websocket Chanel Events', () => {
let store;
let mockServer;
beforeAll(async () => {
store = await configureStore();
await TestHelper.initBasic(Client4);
const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:');
mockServer = new Server(connUrl);
return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
});
afterAll(async () => {
Actions.close()();
mockServer.stop();
await TestHelper.tearDown();
});
it('Websocket Handle Channel Member Updated', async () => {
const channelMember = TestHelper.basicChannelMember;
const mockStore = configureMockStore([thunk]);
const st = mockStore(globalInitialState);
await st.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
channelMember.roles = 'channel_user channel_admin';
const rolesToLoad = channelMember.roles.split(' ');
nock(Client4.getRolesRoute()).
post('/names', JSON.stringify(rolesToLoad)).
reply(200, rolesToLoad);
mockServer.emit('message', JSON.stringify({
event: WebsocketEvents.CHANNEL_MEMBER_UPDATED,
data: {
channelMember: JSON.stringify(channelMember),
},
}));
await TestHelper.wait(300);
const storeActions = st.getActions();
const batch = storeActions.find((a) => a.type === 'BATCH_WS_CHANNEL_MEMBER_UPDATE');
expect(batch).not.toBeNull();
const memberAction = batch.payload.find((a) => a.type === ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER);
expect(memberAction).not.toBeNull();
const rolesActions = batch.payload.find((a) => a.type === RoleTypes.RECEIVED_ROLES);
expect(rolesActions).not.toBeNull();
expect(rolesActions.data).toEqual(rolesToLoad);
});
it('Websocket Handle Channel Created', async () => {
const channelId = TestHelper.basicChannel.id;
const channel = {id: channelId, display_name: 'test', name: TestHelper.basicChannel.name};
await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: channel});
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CHANNEL_CREATED, data: {channel_id: channelId, team_id: TestHelper.basicTeam.id}, broadcast: {omit_users: null, user_id: 't36kso9nwtdhbm8dbkd6g4eeby', channel_id: '', team_id: ''}, seq: 57}));
await TestHelper.wait(300);
const state = store.getState();
const entities = state.entities;
const {channels} = entities.channels;
assert.ok(channels[channel.id]);
});
it('Websocket Handle Channel Updated', async () => {
const channelName = 'Test name';
const channelId = TestHelper.basicChannel.id;
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CHANNEL_UPDATED, data: {channel: `{"id":"${channelId}","create_at":1508253647983,"update_at":1508254198797,"delete_at":0,"team_id":"55pfercbm7bsmd11p5cjpgsbwr","type":"O","display_name":"${channelName}","name":"${TestHelper.basicChannel.name}","header":"header","purpose":"","last_post_at":1508253648004,"total_msg_count":0,"extra_update_at":1508253648001,"creator_id":"${TestHelper.basicUser.id}"}`}, broadcast: {omit_users: null, user_id: '', channel_id: channelId, team_id: ''}, seq: 62}));
await TestHelper.wait(300);
const state = store.getState();
const entities = state.entities;
const {channels} = entities.channels;
assert.strictEqual(channels[channelId].display_name, channelName);
});
it('Websocket Handle Channel Deleted', async () => {
const time = Date.now();
await store.dispatch(TeamActions.selectTeam(TestHelper.basicTeam));
await store.dispatch(ChannelActions.selectChannel(TestHelper.basicChannel.id));
store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: {id: TestHelper.generateId(), name: General.DEFAULT_CHANNEL, team_id: TestHelper.basicTeam.id, display_name: General.DEFAULT_CHANNEL}});
store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: TestHelper.basicChannel});
nock(Client4.getUserRoute('me')).
get(`/teams/${TestHelper.basicTeam.id}/channels/members`).
reply(201, [{user_id: TestHelper.basicUser.id, channel_id: TestHelper.basicChannel.id}]);
mockServer.emit('message', JSON.stringify({
event: WebsocketEvents.CHANNEL_DELETED,
data: {
channel_id: TestHelper.basicChannel.id,
delete_at: time,
},
broadcast: {
omit_users: null,
user_id: '',
channel_id: '',
team_id: TestHelper.basicTeam.id,
},
seq: 68,
}));
await TestHelper.wait(300);
const state = store.getState();
const entities = state.entities;
const {channels, currentChannelId} = entities.channels;
assert.ok(channels[currentChannelId].name === General.DEFAULT_CHANNEL);
});
it('Websocket Handle Channel Unarchive', async () => {
await store.dispatch(TeamActions.selectTeam(TestHelper.basicTeam));
await store.dispatch(ChannelActions.selectChannel(TestHelper.basicChannel.id));
store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: {id: TestHelper.generateId(), name: General.DEFAULT_CHANNEL, team_id: TestHelper.basicTeam.id, display_name: General.DEFAULT_CHANNEL}});
store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: TestHelper.basicChannel});
nock(Client4.getUserRoute('me')).
get(`/teams/${TestHelper.basicTeam.id}/channels/members`).
reply(201, [{user_id: TestHelper.basicUser.id, channel_id: TestHelper.basicChannel.id}]);
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CHANNEL_UNARCHIVE, data: {channel_id: TestHelper.basicChannel.id}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: TestHelper.basicTeam.id}, seq: 68}));
await TestHelper.wait(300);
const state = store.getState();
const entities = state.entities;
const {channels, currentChannelId} = entities.channels;
assert.ok(channels[currentChannelId].delete_at === 0);
});
it('Websocket Handle Direct Channel', async () => {
const channel = {id: TestHelper.generateId(), name: TestHelper.basicUser.id + '__' + TestHelper.generateId(), type: 'D'};
nock(Client4.getChannelsRoute()).
get(`/${channel.id}/members/me`).
reply(201, {user_id: TestHelper.basicUser.id, channel_id: channel.id});
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.DIRECT_ADDED, data: {teammate_id: 'btaxe5msnpnqurayosn5p8twuw'}, broadcast: {omit_users: null, user_id: '', channel_id: channel.id, team_id: ''}, seq: 2}));
store.dispatch({type: ChannelTypes.RECEIVED_CHANNEL, data: channel});
await TestHelper.wait(300);
const {channels} = store.getState().entities.channels;
assert.ok(Object.keys(channels).length);
});
});

View File

@@ -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 '@client/rest';
import {ChannelTypes, TeamTypes, RoleTypes} from '@mm-redux/action_types';
import {General} from '@mm-redux/constants';
import {
getAllChannels,
getChannel,
getChannelsNameMapInTeam,
getCurrentChannelId,
getRedirectChannelNameForTeam,
} from '@mm-redux/selectors/entities/channels';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions';
import {WebSocketMessage} from '@mm-redux/types/websocket';
import {getChannelByName} from '@mm-redux/utils/channel_utils';
import EventEmitter from '@mm-redux/utils/event_emitter';
export function handleChannelConvertedEvent(msg: WebSocketMessage) {
return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => {
const channelId = msg.data.channel_id;
if (channelId) {
const channel = getChannel(getState(), channelId);
if (channel) {
dispatch({
type: ChannelTypes.RECEIVED_CHANNEL,
data: {...channel, type: General.PRIVATE_CHANNEL},
});
}
}
return {data: true};
};
}
export function handleChannelCreatedEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
const {channel_id: channelId, team_id: teamId} = msg.data;
const state = getState();
const channels = getAllChannels(state);
const currentTeamId = getCurrentTeamId(state);
if (teamId === currentTeamId && !channels[channelId]) {
const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id);
if (channelActions.length) {
dispatch(batchActions(channelActions, 'BATCH_WS_CHANNEL_CREATED'));
}
}
return {data: true};
};
}
export function handleChannelDeletedEvent(msg: WebSocketMessage) {
return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => {
const state = getState();
const currentChannelId = getCurrentChannelId(state);
const currentTeamId = getCurrentTeamId(state);
const config = getConfig(state);
const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true';
const actions: Array<GenericAction> = [{
type: ChannelTypes.RECEIVED_CHANNEL_DELETED,
data: {
id: msg.data.channel_id,
deleteAt: msg.data.delete_at,
team_id: msg.broadcast.team_id,
viewArchivedChannels,
},
}];
if (msg.broadcast.team_id === currentTeamId) {
if (msg.data.channel_id === currentChannelId && !viewArchivedChannels) {
const channelsInTeam = getChannelsNameMapInTeam(state, currentTeamId);
const channel = getChannelByName(channelsInTeam, getRedirectChannelNameForTeam(state, currentTeamId));
if (channel && channel.id) {
actions.push({type: ChannelTypes.SELECT_CHANNEL, data: channel.id});
}
EventEmitter.emit(General.DEFAULT_CHANNEL, '');
}
}
dispatch(batchActions(actions, 'BATCH_WS_CHANNEL_ARCHIVED'));
return {data: true};
};
}
export function handleChannelMemberUpdatedEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc): Promise<ActionResult> => {
try {
const channelMember = JSON.parse(msg.data.channelMember);
const rolesToLoad = channelMember.roles.split(' ');
const actions: Array<GenericAction> = [{
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
data: channelMember,
}];
const roles = await Client4.getRolesByNames(rolesToLoad);
if (roles.length) {
actions.push({
type: RoleTypes.RECEIVED_ROLES,
data: roles,
});
}
dispatch(batchActions(actions, 'BATCH_WS_CHANNEL_MEMBER_UPDATE'));
} catch {
//do nothing
}
return {data: true};
};
}
export function handleChannelSchemeUpdatedEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc): Promise<ActionResult> => {
const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id);
if (channelActions.length) {
dispatch(batchActions(channelActions, 'BATCH_WS_SCHEME_UPDATE'));
}
return {data: true};
};
}
export function handleChannelUnarchiveEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
const state = getState();
const currentTeamId = getCurrentTeamId(state);
const config = getConfig(state);
const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true';
if (msg.broadcast.team_id === currentTeamId) {
const actions: Array<GenericAction> = [{
type: ChannelTypes.RECEIVED_CHANNEL_UNARCHIVED,
data: {
id: msg.data.channel_id,
team_id: msg.data.team_id,
deleteAt: 0,
viewArchivedChannels,
},
}];
const {data: myData}: any = await dispatch(loadChannelsForTeam(currentTeamId, true));
if (myData?.channels && myData?.channelMembers) {
actions.push({
type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS,
data: myData,
});
}
dispatch(batchActions(actions, 'BATCH_WS_CHANNEL_UNARCHIVED'));
}
return {data: true};
};
}
export function handleChannelUpdatedEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
let channel;
try {
channel = msg.data ? JSON.parse(msg.data.channel) : null;
} catch (err) {
return {error: err};
}
const currentChannelId = getCurrentChannelId(getState());
if (channel) {
dispatch({
type: ChannelTypes.RECEIVED_CHANNEL,
data: channel,
});
if (currentChannelId === channel.id) {
// Emit an event with the channel received as we need to handle
// the changes without listening to the store
EventEmitter.emit(WebsocketEvents.CHANNEL_UPDATED, channel);
}
}
return {data: true};
};
}
export function handleChannelViewedEvent(msg: WebSocketMessage) {
return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => {
const state = getState();
const {channel_id: channelId} = msg.data;
const currentChannelId = getCurrentChannelId(state);
const currentUserId = getCurrentUserId(state);
if (channelId !== currentChannelId && currentUserId === msg.broadcast.user_id) {
dispatch(markChannelAsRead(channelId, undefined, false));
}
return {data: true};
};
}
export function handleDirectAddedEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc): Promise<ActionResult> => {
const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id);
if (channelActions.length) {
dispatch(batchActions(channelActions, 'BATCH_WS_DM_ADDED'));
}
return {data: true};
};
}
export function handleUpdateMemberRoleEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc): Promise<ActionResult> => {
const memberData = JSON.parse(msg.data.member);
const roles = memberData.roles.split(' ');
const actions = [];
try {
const newRoles = await Client4.getRolesByNames(roles);
if (newRoles.length) {
actions.push({
type: RoleTypes.RECEIVED_ROLES,
data: newRoles,
});
}
} catch (error) {
return {error};
}
actions.push({
type: TeamTypes.RECEIVED_MY_TEAM_MEMBER,
data: memberData,
});
dispatch(batchActions(actions));
return {data: true};
};
}

View File

@@ -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 '@client/rest';
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');
});
});

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