Compare commits

..

55 Commits

Author SHA1 Message Date
Mattermost Build
41848b2634 Bump app build number to 307 (#4503)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-06-26 22:04:54 -04:00
Mattermost Build
8d81d946c5 Automated cherry pick of #4499 (#4501)
* Revert updated dependecies for SSO

* Fix test setup

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-06-26 21:58:26 -04:00
Mattermost Build
10d27ee5ba Fix SSO login with subpaths (#4495)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-06-26 16:47:40 -04:00
Elias Nahum
647def15be Bump Version to 1.32.2 and Build number to 306 (#4494)
* Bump app version number to 1.32.2

* Bump app build number to 306
2020-06-26 16:45:23 -04:00
Mattermost Build
da440e50fb Automated cherry pick of #4489 (#4492)
* Avoid throwing when purging

* Update app/store/index.ts

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

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-06-26 16:20:17 -04:00
Miguel Alatzar
793ac98d74 Bump build number to 305 (#4477) 2020-06-23 18:50:25 -07:00
Mattermost Build
fab353b494 Various fixes: (#4475)
* Fix map call on Set
* Ensure we don't destructure non-iterable values
* Include error message and stack in Alert

Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-06-23 16:50:37 -07:00
Miguel Alatzar
8853b5dd45 Bump app build number to 304 (#4468) 2020-06-22 15:38:45 -07:00
Mattermost Build
464d93df8d Various fixes: (#4466)
* Dispatch REHYDRATED if already hydrated after getStoredState call
* Empty/null prev version is valid
* Update iOS target to 11.0

Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-06-22 15:31:27 -07:00
Miguel Alatzar
47f126b71f Bump build number (#4459) 2020-06-19 16:53:27 -07:00
Miguel Alatzar
4b2a0c7aea Bump version number (#4458) 2020-06-19 16:45:07 -07:00
Mattermost Build
f6bedbb7d6 Automated cherry pick of #4452 (#4456)
* Remove withEncryption

* Remove warm up

* Downgrade react-native-keychain

Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-06-19 16:19:31 -07:00
Mattermost Build
b5ee7c8908 Bump app build number to 302 (#4426)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-06-15 14:21:56 -04:00
Mattermost Build
aab0814b7f Automated cherry pick of #4422 (#4424)
* Fix SSO and clear cookies

* Fix unit tests

* Removed unnecessary argument

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

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-06-15 14:06:53 -04:00
Miguel Alatzar
bf840785fb Bump app build number to 301 (#4420) 2020-06-12 13:22:38 -07:00
Mattermost Build
514e9cfd08 Automated cherry pick of #4418 (#4419)
* Wrap await calls in try/catch

* Fix spacing

Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-06-12 13:15:35 -07:00
Mattermost Build
b5c3e95a4b Bump app build number to 300 (#4415)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-06-11 13:45:41 -07:00
Mattermost Build
e6547d7dc1 MM-25967 await until cookies are cleared (#4413)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-06-11 15:13:21 -04:00
Mattermost Build
2b8bba7c24 Bump app build number to 299 (#4405)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-06-09 16:11:59 -04:00
Mattermost Build
9b9373e27b MM-25929 Decouple id-loaded retries from regular notification run (#4403)
Bug found in #4302 that delayed delivery/receipt of normal (non id-loaded) iOS push notifications. Response handling was happening within the guard block for id-loaded messages.

Co-authored-by: Amit Uttam <changingrainbows@gmail.com>
2020-06-09 16:15:25 -03:00
Mattermost Build
bc25a29c42 MM-25782 improve channel member reducer speed to sync memberships (#4402)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-06-09 14:39:48 -04:00
Elias Nahum
4c4dd8297d translations PR 20200608 (#4398) 2020-06-08 21:54:36 -04:00
Mattermost Build
c3fc53a071 Removed unnecessary trim (#4399)
Co-authored-by: marianunez <maria.nunez@mattermost.com>
2020-06-08 21:43:40 -04:00
Mattermost Build
34af598a6d clear cookies on again trying to login (#4395)
Co-authored-by: Harshit Khetan <khetanmehul@gmail.com>
2020-06-08 15:27:14 -04:00
Mattermost Build
ff89f3530e Ensure previous state is cleared when logout (#4396)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-06-08 15:26:53 -04:00
Mattermost Build
8a95000bd0 MM-25831 Fix timing issue with cancelPing (#4394)
With `getUrl` recently [becoming async](https://github.com/mattermost/mattermost-mobile/commit/293470ff#diff-60b06b1c4aab028b96d9b207e84000c6R316), the global definition of the cancelPing function wasn't being made available in time for use by other functions (e.g. `handleSslProblem` and `handleConnect`)
2020-06-08 14:24:16 -03:00
Mattermost Build
21e1466068 MM-25849 Apply backoff & retry logic only for ID-loaded push notifications (#4393)
Changes in #4302 piggy-backed on the existing [exception catching logic](https://github.com/mattermost/mattermost-mobile/pull/4302/files#diff-266cddcf80a6bc300b40cc922e7a659bL101-L102) to retry failed request (status != 200). However, the change applied retries for any type of push notification.
2020-06-08 12:54:14 -03:00
Mattermost Build
4ebcba6069 Bump app build number to 298 (#4383)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-06-03 10:36:53 -07:00
Elias Nahum
b34ce42016 translations PR 20200601 (#4378) 2020-06-03 08:53:10 -04:00
Mattermost Build
97d393a2ba MM-25694 Disallow profile picture update is set by LDAP (#4379)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-06-02 12:11:53 -04:00
Mattermost Build
fb03a88304 Automated cherry pick of #4372 (#4376)
* Handle SSO redirecting to a different URL than specified by the user

* Set Server URL based on the last redirect

* Improve last redirect condition

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-06-01 16:22:28 -04:00
Mattermost Build
6e239d5566 Automated cherry pick of #4357 (#4369) 2020-05-29 09:43:02 -04:00
Mattermost Build
72b95fa265 Bump app build number to 297 (#4367)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-05-28 14:03:11 -04:00
Mattermost Build
d19fc71ad4 Bump app build number to 296 (#4364)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-05-27 15:45:17 -04:00
Mattermost Build
526290bbdf MM-25562 Fix dismiss reaction list crash (#4363)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-05-27 14:17:22 -04:00
Mattermost Build
962b38d024 Bump upload timeout to 1 min (#4359)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-05-27 13:02:44 -04:00
Mattermost Build
51109c74d3 Invalidate versions for iOS (#4356)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-05-27 12:44:20 -04:00
Mattermost Build
098230e79e Automated cherry pick of #4344 (#4348)
* Fix emoji autocomplete results

* Rename selectors with "select" prefix

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-05-27 07:42:23 -04:00
Mattermost Build
8fa67bd5b4 Automated cherry pick of #4346 (#4351)
* MM-25510 Increase post options long press delay to 250ms

* Set delay to 200ms

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-05-26 20:17:52 -04:00
Elias Nahum
6d7749a098 translations PR 20200525 (#4343) 2020-05-26 10:04:51 -07:00
Mattermost Build
37479587cc Add calls to Client4.savePreferences (#4347)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-05-26 10:00:41 -07:00
Mattermost Build
3708b86b30 MM-25434 Fix switch team badge cut off (#4339)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-05-22 10:57:38 -07:00
Mattermost Build
cabce2a808 Bump app build number to 295 (#4336)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-05-22 10:29:29 -07:00
Mattermost Build
4abb483f2c Apply background style for Android as well (#4329)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-05-22 11:49:50 -04:00
Mattermost Build
7a0bf1dc77 Update control icon style (#4328)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-05-22 11:49:37 -04:00
Mattermost Build
6cf1140a0f Incrase redux-persist timeout to 1 min (#4324)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-05-21 14:24:19 -07:00
Mattermost Build
9506875683 Catch ClassCastException activity (#4323)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-05-21 12:28:15 -07:00
Mattermost Build
679a897848 Bump app build number to 294 (#4321)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-05-21 09:38:02 -07:00
Mattermost Build
b5fd0284e8 Bump app version number to 1.32.0 (#4319)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-05-21 09:11:11 -07:00
Mattermost Build
67eea1750d Fix overflow of searchbar for iOS in Landscape (#4316)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-05-21 11:01:19 -04:00
Mattermost Build
d4e405485b Do not preload images with FastImage (#4315)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-05-20 16:17:49 -04:00
Mattermost Build
1389e4f7f7 Handle MFA error in MFA screen (#4313)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-05-20 08:37:01 -07:00
Mattermost Build
7cf4084fe5 Automated cherry pick of #4305 (#4309)
* Fixes Android Share Extension

* Revert changes to share extension navigation

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-05-20 08:05:29 -04:00
Mattermost Build
6b7cffd6af Automated cherry pick of #4295 (#4307)
* Allow interaction when the in-app notification is shown

* Wrap in gesture HOC only for Android

* Set height and remove flex for android in-app notifications gesture hoc

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-05-19 16:09:33 -07:00
Mattermost Build
dba3278c9f Automated cherry pick of #4304 (#4306)
* Fix infinite skeleton in different use cases

* Apply suggestions from code review

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

* Update app/utils/teams.js

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-05-19 16:08:58 -07:00
1076 changed files with 31868 additions and 66048 deletions

View File

@@ -1,6 +1,4 @@
version: 2.1
orbs:
owasp: entur/owasp@0.0.10
executors:
android:
@@ -13,7 +11,7 @@ executors:
NODE_ENV: production
BABEL_ENV: production
docker:
- image: circleci/android:api-29-node
- image: circleci/android:api-27-node
working_directory: ~/mattermost-mobile
resource_class: <<parameters.resource_class>>
@@ -23,7 +21,7 @@ executors:
NODE_ENV: production
BABEL_ENV: production
macos:
xcode: "12.0.0"
xcode: "11.0.0"
working_directory: ~/mattermost-mobile
shell: /bin/bash --login -o pipefail
@@ -44,6 +42,7 @@ commands:
for:
type: string
steps:
- ruby-setup
- restore_cache:
name: Restore Fastlane cache
key: v1-gems-<< parameters.for >>-{{ checksum "fastlane/Gemfile.lock" }}-{{ arch }}
@@ -81,7 +80,7 @@ commands:
key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }}
- run:
name: Generate assets
command: node ./scripts/generate-assets.js
command: make dist/assets
- save_cache:
name: Save assets cache
key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }}
@@ -103,8 +102,8 @@ commands:
paths:
- node_modules
- run:
name: "Patch dependencies"
command: npx patch-package
name: "Run post install scripts"
command: make post-install
pods-dependencies:
description: "Get cocoapods dependencies"
@@ -112,12 +111,10 @@ commands:
- restore_cache:
name: Restore cocoapods specs and pods
key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
- run:
name: iOS gems
command: npm run ios-gems
- run:
name: Getting cocoapods dependencies
command: npm run pod-install
working_directory: ios
command: pod install
- save_cache:
name: Save cocoapods specs and pods cache
key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
@@ -144,14 +141,11 @@ commands:
echo MATTERMOST_RELEASE_STORE_FILE=${STORE_FILE} | tee -a android/gradle.properties > /dev/null
echo ${STORE_ALIAS} | tee -a android/gradle.properties > /dev/null
echo ${STORE_PASSWORD} | tee -a android/gradle.properties > /dev/null
- run:
name: Jetify android libraries
command: ./node_modules/.bin/jetify
- run:
working_directory: fastlane
name: Run fastlane to build android
no_output_timeout: 30m
command: export TERM=xterm && bundle exec fastlane android build
command: bundle exec fastlane android build
build-ios:
description: "Build the iOS app"
@@ -169,7 +163,7 @@ commands:
no_output_timeout: 30m
command: |
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
export TERM=xterm && bundle exec fastlane ios build
bundle exec fastlane ios build
deploy-to-store:
description: "Deploy build to store"
@@ -202,13 +196,14 @@ commands:
filename:
type: string
steps:
- run:
name: Copying artifacts
command: |
mkdir /tmp/artifacts;
cp ~/mattermost-mobile/<<parameters.filename>> /tmp/artifacts;
- store_artifacts:
path: /tmp/artifacts
path: ~/mattermost-mobile/<<parameters.filename>>
ruby-setup:
steps:
- run:
name: Set Ruby Version
command: echo "ruby-2.6.3" > ~/.ruby-version
jobs:
test:
@@ -228,59 +223,7 @@ jobs:
command: npm test
- run:
name: Check i18n
command: ./scripts/precommit/i18n.sh
check-deps:
parameters:
cve_data_directory:
type: string
default: "~/.owasp/dependency-check-data"
working_directory: ~/mattermost-mobile
executor: owasp/default
environment:
version_url: "https://jeremylong.github.io/DependencyCheck/current.txt"
executable_url: "https://dl.bintray.com/jeremy-long/owasp/dependency-check-VERSION-release.zip"
steps:
- checkout
- restore_cache:
name: Restore npm cache
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
- run:
name: Checkout config
command: cd .. && git clone https://github.com/mattermost/security-automation-config
- run:
name: Install Go
command: sudo apt-get update && sudo apt-get install golang
- owasp/with_commandline:
steps:
# Taken from https://github.com/entur/owasp-orb/blob/master/src/%40orb.yml#L349-L361
- owasp/generate_cache_keys:
cache_key: commmandline-default-cache-key-v6
- owasp/restore_owasp_cache
- run:
name: Update OWASP Dependency-Check Database
command: |
if ! ~/.owasp/dependency-check/bin/dependency-check.sh --data << parameters.cve_data_directory >> --updateonly; then
# Update failed, probably due to a bad DB version; delete cached DB and try again
rm -rv ~/.owasp/dependency-check-data/*.db
~/.owasp/dependency-check/bin/dependency-check.sh --data << parameters.cve_data_directory >> --updateonly
fi
- owasp/store_owasp_cache:
cve_data_directory: <<parameters.cve_data_directory>>
- run:
name: Run OWASP Dependency-Check Analyzer
command: |
~/.owasp/dependency-check/bin/dependency-check.sh \
--data << parameters.cve_data_directory >> --format ALL --noupdate --enableExperimental \
--propertyfile ../security-automation-config/dependency-check/dependencycheck.properties \
--suppression ../security-automation-config/dependency-check/suppression.xml \
--suppression ../security-automation-config/dependency-check/suppression.$CIRCLE_PROJECT_REPONAME.xml \
--scan './**/*' || true
- owasp/collect_reports:
persist_to_workspace: false
- run:
name: Post results to Mattermost
command: go run ../security-automation-config/dependency-check/post_results.go
command: make i18n-extract-ci
build-android-beta:
executor: android
@@ -288,7 +231,7 @@ jobs:
- build-android
- persist
- save:
filename: "*.apk"
filename: "Mattermost_Beta.apk"
build-android-release:
executor: android
@@ -296,7 +239,7 @@ jobs:
- build-android
- persist
- save:
filename: "*.apk"
filename: "Mattermost.apk"
build-android-pr:
executor: android
@@ -305,7 +248,7 @@ jobs:
steps:
- build-android
- save:
filename: "*.apk"
filename: "Mattermost_Beta.apk"
build-android-unsigned:
executor: android
@@ -317,9 +260,6 @@ jobs:
- fastlane-dependencies:
for: android
- gradle-dependencies
- run:
name: Jetify Android libraries
command: ./node_modules/.bin/jetify
- run:
working_directory: fastlane
name: Run fastlane to build unsigned android
@@ -327,7 +267,7 @@ jobs:
command: bundle exec fastlane android unsigned
- persist
- save:
filename: "*.apk"
filename: "Mattermost-unsigned.apk"
build-ios-beta:
executor: ios
@@ -335,7 +275,7 @@ jobs:
- build-ios
- persist
- save:
filename: "*.ipa"
filename: "Mattermost_Beta.ipa"
build-ios-release:
executor: ios
@@ -343,7 +283,7 @@ jobs:
- build-ios
- persist
- save:
filename: "*.ipa"
filename: "Mattermost.ipa"
build-ios-pr:
executor: ios
@@ -352,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
@@ -374,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:
@@ -455,10 +377,6 @@ workflows:
build:
jobs:
- test
- check-deps:
context: sast-webhook
requires:
- test
- build-android-release:
context: mattermost-mobile-android-release
@@ -577,17 +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+$/
- github-release:
context: mattermost-mobile-unsigned
requires:
@@ -597,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

@@ -23,29 +23,18 @@
},
"rules": {
"global-require": 0,
"no-undefined": 0,
"react/display-name": [2, { "ignoreTranspilerName": false }],
"react/jsx-filename-extension": 0,
"camelcase": [
0,
{
"properties": "never"
}
],
"@typescript-eslint/ban-types": 0,
"react/jsx-filename-extension": [2, {"extensions": [".js"]}],
"no-undefined": 0,
"no-nested-ternary": 0,
"@typescript-eslint/camelcase": 0,
"@typescript-eslint/no-undefined": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/no-unused-vars": [
2,
{
"vars": "all",
"args": "after-used"
}
],
"@typescript-eslint/no-unused-vars": 2,
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-use-before-define": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/explicit-module-boundary-types": "off"
"@typescript-eslint/explicit-function-return-type": 0
},
"overrides": [
{
@@ -53,23 +42,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

@@ -71,4 +71,4 @@ untyped-import
untyped-type-import
[version]
^0.122.0
^0.113.0

7
.gitignore vendored
View File

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

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.0.rc.1"
gem "cocoapods", "1.7.5"

View File

@@ -2,27 +2,23 @@ GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.2)
activesupport (5.2.4.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 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.4)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
atomos (0.1.3)
claide (1.0.3)
cocoapods (1.10.0.rc.1)
addressable (~> 2.6)
cocoapods (1.7.5)
activesupport (>= 4.0.2, < 5)
claide (>= 1.0.2, < 2.0)
cocoapods-core (= 1.10.0.rc.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.17.0, < 2.0)
cocoapods-core (1.10.0.rc.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.7)
concurrent-ruby (1.1.5)
escape (0.0.4)
ethon (0.12.0)
ffi (>= 1.3.0)
ffi (1.13.1)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
httpclient (2.8.3)
i18n (1.8.5)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
json (2.3.1)
minitest (5.14.2)
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.7)
tzinfo (1.2.6)
thread_safe (~> 0.1)
xcodeproj (1.18.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.0.rc.1)
cocoapods (= 1.7.5)
BUNDLED WITH
2.1.4
2.0.2

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

View File

@@ -113,20 +113,18 @@ SOFTWARE.
---
## @react-native-community/clipboard
## @react-native-community/cookies
This product contains '@react-native-community/clipboard' by React Native Community.
This product contains '@react-native-community/cookies' by React Native Community.
React Native Clipboard API for both iOS and Android
Cookie Manager for React Native
* HOMEPAGE:
* https://github.com/react-native-community/clipboard
* https://github.com/react-native-community/cookies
* LICENSE: MIT
* LICENSE: MIT License
MIT License
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2020 React Native Community
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -1521,20 +1519,22 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
## react-native-cookies
## react-native-circular-progress
This product contains a modified version of 'react-native-cookies' by Joseph P. Ferraro.
This product contains 'react-native-circular-progress' by Bart Gryszko.
Cookie manager for react native.
React Native component for creating animated, circular progress with react-native-svg
* HOMEPAGE:
* https://github.com/joeferraro/react-native-cookies
* https://github.com/bgryszko/react-native-circular-progress
* LICENSE: MIT
The MIT License (MIT)
Copyright (c) 2015 shimo
Copyright (c) 2015 Bart Gryszko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -1543,16 +1543,20 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
@@ -1855,6 +1859,30 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
---
## react-native-image-gallery
This product contains a modified version of 'react-native-image-gallery' by Archriss.
Pure JavaScript image gallery component for iOS and Android
* HOMEPAGE:
* https://github.com/archriss/react-native-image-gallery#readme
* LICENSE: ISC
Note: An original license file for this dependency is not available. We determined the type of license based on the package registry entry for this project. The following text has been prepared using a template from the SPDX Workgroup (https://spdx.org) for this type of license.
ISC License:
Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC")
Copyright (c) 1995-2003 by Internet Software Consortium
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
## react-native-image-picker
This product contains 'react-native-image-picker' by Marc Shilling.
@@ -1892,12 +1920,12 @@ SOFTWARE.
## react-native-keyboard-aware-scroll-view
This product contains a modified version of 'react-native-keyboard-aware-scroll-view' by APSL.
This product contains 'react-native-keyboard-aware-scroll-view' by Alvaro Medina Ballester.
A ScrollView component that handles keyboard appearance and automatically scrolls to focused TextInput.
A React Native ScrollView component that resizes when the keyboard appears.
* HOMEPAGE:
* https://github.com/APSL/react-native-keyboard-aware-scroll-view
* https://github.com/APSL/react-native-keyboard-aware-scroll-view#readme
* LICENSE: MIT
@@ -2289,39 +2317,6 @@ SOFTWARE.
---
## react-native-redash
This product contains 'react-native-redash' by William Candillon.
The React Native Reanimated and Gesture Handler Toolbelt.
* HOMEPAGE:
* https://github.com/wcandillon/react-native-redash
* LICENSE: MIT License
Copyright (c) 2020 William Candillon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-safe-area
This product contains 'react-native-safe-area' by Masayuki Iwai.
@@ -2450,39 +2445,6 @@ limitations under the License.
---
## react-native-share
This product contains 'react-native-share' by react-native-share.
React Native Share, a simple tool for share message and file to other apps.
* HOMEPAGE:
* https://github.com/react-native-share/react-native-share
* LICENSE: The MIT License (MIT)
Copyright (c) 2015 Esteban Fuentealba
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-slider
This product contains 'react-native-slider' by Jean Regisser.
@@ -2578,6 +2540,41 @@ SOFTWARE.
---
## react-native-v8
This product contains 'react-native-v8' by Kudo Chien.
Opt-in V8 runtime for React Native Android
* HOMEPAGE:
* https://github.com/Kudo/react-native-v8
* LICENSE: MIT
MIT License
Copyright (c) 2019 Kudo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-vector-icons
This product contains 'react-native-vector-icons' by Joel Arvidsson.

View File

@@ -1,6 +1,6 @@
# Mattermost Mobile
- **Minimum Server versions:** Current ESR version (5.25)
- **Minimum Server versions:** Current ESR version (5.19)
- **Supported iOS versions:** 11+
- **Supported Android versions:** 7.0+

View File

@@ -101,14 +101,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.
@@ -132,11 +133,13 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
versionCode 338
versionName "1.38.1"
versionCode 307
versionName "1.32.2"
multiDexEnabled = true
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
ndk {
abiFilters 'armeabi-v7a','arm64-v8a','x86','x86_64'
}
}
signingConfigs {
release {
@@ -152,7 +155,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 +184,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
}
}
}
@@ -191,6 +194,14 @@ android {
targetCompatibility 1.8
}
packagingOptions {
// Make sure libjsc.so does not packed in APK
exclude "**/libjsc.so"
pickFirst "lib/armeabi-v7a/libc++_shared.so"
pickFirst "lib/arm64-v8a/libc++_shared.so"
pickFirst "lib/x86/libc++_shared.so"
pickFirst "lib/x86_64/libc++_shared.so"
}
}
repositories {
@@ -250,7 +261,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation project(':reactnativenotifications')
implementation "com.google.firebase:firebase-messaging:$firebaseVersion"
implementation 'com.google.firebase:firebase-messaging:17.3.4'
// For animated GIF support
implementation 'com.facebook.fresco:fresco:2.0.0'
@@ -258,8 +269,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

@@ -19,13 +19,12 @@
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"

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

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

@@ -106,27 +106,8 @@ public class ReceiptDelivery {
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;
}
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"};

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,8 +14,6 @@
<string name="allowOtherServers_description">Allow the user to change the above server URL.</string>
<string name="username_title">Default Username</string>
<string name="username_description">Set the username or email address to use to authenticate against the Mattermost Server.</string>
<string name="timeout_title">Default Request Timeout</string>
<string name="timeout_description">How long in milliseconds the mobile app should wait for the server to respond.</string>
<string name="vendor_title">EMM Vendor or Company Name</string>
<string name="vendor_description">Name of the EMM vendor or company deploying the app. Used in help text when prompting for passcodes so users are aware why the app is being protected.</string>
</resources>

View File

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

View File

@@ -43,12 +43,6 @@
android:description="@string/username_description"
android:restrictionType="string"
android:defaultValue="" />
<restriction
android:key="timeout"
android:title="@string/timeout_title"
android:description="@string/timeout_description"
android:restrictionType="string"
android:defaultValue="10000" />
<restriction
android:key="vendor"
android:title="@string/vendor_title"

View File

@@ -2,13 +2,12 @@
buildscript {
ext {
buildToolsVersion = "29.0.2"
buildToolsVersion = "28.0.3"
minSdkVersion = 24
compileSdkVersion = 29
targetSdkVersion = 29
compileSdkVersion = 28
targetSdkVersion = 28
supportLibVersion = "28.0.0"
kotlinVersion = "1.3.61"
firebaseVersion = "21.0.0"
RNNKotlinVersion = kotlinVersion
}
@@ -19,9 +18,9 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.3'
classpath 'com.android.tools.build:gradle:3.5.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
@@ -49,17 +48,17 @@ 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"
@@ -67,8 +66,5 @@ allprojects {
maven {
url ("https://dl.bintray.com/rudderstack/rudderstack")
}
maven {
url "$rootDir/../node_modules/detox/Detox-android"
}
}
}

View File

@@ -30,4 +30,4 @@ android.useAndroidX=true
android.enableJetifier=true
# Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.37.0
FLIPPER_VERSION=0.33.1

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

29
android/gradlew vendored
View File

@@ -154,19 +154,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 +175,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"
# 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" "$@"

27
android/gradlew.bat vendored
View File

@@ -13,91 +13,64 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

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

@@ -6,7 +6,7 @@ import {DeviceTypes} from 'app/constants';
export function connection(isOnline) {
return async (dispatch, getState) => {
const state = getState();
if (isOnline !== undefined && isOnline !== state.device.connection) {
if (isOnline !== undefined && isOnline !== state.device.connection) { //eslint-disable-line no-undefined
dispatch({
type: DeviceTypes.CONNECTION_CHANGED,
data: isOnline,

View File

@@ -4,11 +4,11 @@
import {ChannelTypes, PreferenceTypes, RoleTypes, UserTypes} from '@mm-redux/action_types';
import {Client4} from '@mm-redux/client';
import {General, Preferences} from '@mm-redux/constants';
import {getCurrentChannelId, getRedirectChannelNameForTeam, getChannelsNameMapInTeam} from '@mm-redux/selectors/entities/channels';
import {getCurrentChannelId} 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 {getUserIdFromChannelName, isAutoClosed} from '@mm-redux/utils/channel_utils';
import {getPreferenceKey} from '@mm-redux/utils/preference_utils';
import {ActionResult, GenericAction} from '@mm-redux/types/actions';
@@ -281,43 +281,6 @@ export async function getAddedDmUsersIfNecessary(state: GlobalState, preferences
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);

View File

@@ -7,17 +7,11 @@ import merge from 'deepmerge';
import {Preferences} from '@mm-redux/constants';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import EventEmmiter from '@mm-redux/utils/event_emitter';
import {DeviceTypes, NavigationTypes} from '@constants';
import EphemeralStore from '@store/ephemeral_store';
import Store from '@store/store';
Navigation.setDefaultOptions({
layout: {
orientation: [DeviceTypes.IS_TABLET ? 'all' : 'portrait'],
},
});
const CHANNEL_SCREEN = 'Channel';
function getThemeFromState() {
const state = Store.redux?.getState() || {};
@@ -33,8 +27,8 @@ export function resetToChannel(passProps = {}) {
const stack = {
children: [{
component: {
id: NavigationTypes.CHANNEL_SCREEN,
name: NavigationTypes.CHANNEL_SCREEN,
id: CHANNEL_SCREEN,
name: CHANNEL_SCREEN,
passProps,
options: {
layout: {
@@ -92,8 +86,6 @@ export function resetToChannel(passProps = {}) {
export function resetToSelectServer(allowOtherServers) {
const theme = Preferences.THEMES.default;
EphemeralStore.clearNavigationComponents();
Navigation.setRoot({
root: {
stack: {
@@ -156,8 +148,6 @@ export function resetToTeams(name, title, passProps = {}, options = {}) {
},
};
EphemeralStore.clearNavigationComponents();
Navigation.setRoot({
root: {
stack: {
@@ -192,7 +182,6 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
backButton: {
color: theme.sidebarHeaderTextColor,
title: '',
testID: 'screen.back.button',
},
background: {
color: theme.sidebarHeaderBg,
@@ -234,17 +223,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,
},
@@ -270,7 +252,6 @@ export function showModal(name, title, passProps = {}, options = {}) {
},
};
EphemeralStore.addNavigationModal(name);
Navigation.showModal({
stack: {
children: [{
@@ -332,26 +313,16 @@ export function showSearchModal(initialValue = '') {
visible: false,
height: 0,
},
...Platform.select({
ios: {
modalPresentationStyle: 'pageSheet',
},
}),
};
showModal(name, title, passProps, options);
}
export async function dismissModal(options = {}) {
if (!EphemeralStore.hasModalsOpened()) {
return;
}
const componentId = EphemeralStore.getNavigationTopComponentId();
try {
await Navigation.dismissModal(componentId, options);
EphemeralStore.removeNavigationModal(componentId);
} catch (error) {
// RNN returns a promise rejection if there is no modal to
// dismiss. We'll do nothing in this case.
@@ -359,13 +330,8 @@ export async function dismissModal(options = {}) {
}
export async function dismissAllModals(options = {}) {
if (!EphemeralStore.hasModalsOpened()) {
return;
}
try {
await Navigation.dismissAllModals(options);
EphemeralStore.clearNavigationModals();
} catch (error) {
// RNN returns a promise rejection if there are no modals to
// dismiss. We'll do nothing in this case.
@@ -436,7 +402,7 @@ export function closeMainSideMenu() {
}
Keyboard.dismiss();
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
Navigation.mergeOptions(CHANNEL_SCREEN, {
sideMenu: {
left: {visible: false},
},
@@ -448,7 +414,7 @@ export function enableMainSideMenu(enabled, visible = true) {
return;
}
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
Navigation.mergeOptions(CHANNEL_SCREEN, {
sideMenu: {
left: {enabled, visible},
},
@@ -461,7 +427,7 @@ export function openSettingsSideMenu() {
}
Keyboard.dismiss();
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
Navigation.mergeOptions(CHANNEL_SCREEN, {
sideMenu: {
right: {visible: true},
},
@@ -474,7 +440,7 @@ export function closeSettingsSideMenu() {
}
Keyboard.dismiss();
Navigation.mergeOptions(NavigationTypes.CHANNEL_SCREEN, {
Navigation.mergeOptions(CHANNEL_SCREEN, {
sideMenu: {
right: {visible: false},
},

View File

@@ -7,21 +7,16 @@ import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import merge from 'deepmerge';
import EventEmitter from '@mm-redux/utils/event_emitter';
import * as NavigationActions from '@actions/navigation';
import Preferences from '@mm-redux/constants/preferences';
import EphemeralStore from '@store/ephemeral_store';
import intitialState from '@store/initial_state';
import Store from '@store/store';
import {NavigationTypes} from '@constants';
jest.unmock('@actions/navigation');
jest.mock('@store/ephemeral_store', () => ({
getNavigationTopComponentId: jest.fn(),
clearNavigationComponents: jest.fn(),
addNavigationModal: jest.fn(),
hasModalsOpened: jest.fn().mockReturnValue(true),
}));
const mockStore = configureMockStore([thunk]);
@@ -188,7 +183,6 @@ describe('@actions/navigation', () => {
backButton: {
color: theme.sidebarHeaderTextColor,
title: '',
testID: 'screen.back.button',
},
background: {
color: theme.sidebarHeaderBg,
@@ -235,7 +229,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,
},
@@ -372,7 +366,7 @@ describe('@actions/navigation', () => {
},
};
const defaultOptions = {
modalPresentationStyle: Platform.select({ios: 'pageSheet', android: 'none'}),
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
layout: {
componentBackgroundColor: theme.centerChannelBg,
},
@@ -484,15 +478,4 @@ describe('@actions/navigation', () => {
await NavigationActions.dismissOverlay(topComponentId);
expect(dismissOverlay).toHaveBeenCalledWith(topComponentId);
});
test('dismissAllModalsAndPopToRoot should call Navigation.dismissAllModals, Navigation.popToRoot, and emit event', async () => {
const dismissAllModals = jest.spyOn(Navigation, 'dismissAllModals');
const popToRoot = jest.spyOn(Navigation, 'popToRoot');
EventEmitter.emit = jest.fn();
await NavigationActions.dismissAllModalsAndPopToRoot();
expect(dismissAllModals).toHaveBeenCalled();
expect(popToRoot).toHaveBeenCalledWith(topComponentId);
expect(EventEmitter.emit).toHaveBeenCalledWith(NavigationTypes.NAVIGATION_DISMISS_AND_POP_TO_ROOT);
});
});
});

View File

@@ -5,35 +5,32 @@ import {batchActions} from 'redux-batched-actions';
import {ViewTypes} from 'app/constants';
import {ChannelTypes, RoleTypes, GroupTypes} from '@mm-redux/action_types';
import {ChannelTypes, RoleTypes} from '@mm-redux/action_types';
import {
fetchMyChannelsAndMembers,
getChannelByNameAndTeamName,
joinChannel,
leaveChannel as serviceLeaveChannel,
} from '@mm-redux/actions/channels';
import {getFilesForPost} from '@mm-redux/actions/files';
import {savePreferences} from '@mm-redux/actions/preferences';
import {getLicense} from '@mm-redux/selectors/entities/general';
import {selectTeam} from '@mm-redux/actions/teams';
import {Client4} from '@mm-redux/client';
import {General, Preferences} from '@mm-redux/constants';
import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {
getCurrentChannelId,
getRedirectChannelNameForTeam,
getChannelsNameMapInTeam,
getMyChannelMemberships,
isManuallyUnread,
} from '@mm-redux/selectors/entities/channels';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {getTeamByName, getCurrentTeam} from '@mm-redux/selectors/entities/teams';
import {getTeamByName} from '@mm-redux/selectors/entities/teams';
import {getChannelByName as selectChannelByName, getChannelsIdForTeam} from '@mm-redux/utils/channel_utils';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {lastChannelIdForTeam, loadSidebarDirectMessagesProfiles} from '@actions/helpers/channels';
import {getPosts, getPostsBefore, getPostsSince, loadUnreadChannelPosts} from '@actions/views/post';
import {loadSidebarDirectMessagesProfiles} from '@actions/helpers/channels';
import {getPosts, getPostsBefore, getPostsSince, getPostThread, loadUnreadChannelPosts} from '@actions/views/post';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_draft';
import {getChannelReachable} from '@selectors/channel';
import telemetry from '@telemetry';
@@ -46,21 +43,15 @@ 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 = getTeamByName(state, teamName);
if (!team && errorHandler) {
errorHandler();
return {error: true};
}
if (team && team.id !== currentTeamId) {
await dispatch(fetchMyChannelsAndMembers(team.id));
}
if (!team && errorHandler) {
errorHandler();
}
return {data: true};
if (team && team.id !== currentTeamId) {
await dispatch(fetchMyChannelsAndMembers(team.id));
}
};
}
@@ -119,12 +110,55 @@ export function fetchPostActionWithRetry(action, maxTries = MAX_RETRIES) {
};
}
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));
};
}
@@ -218,15 +252,13 @@ export function handleSelectChannel(channelId) {
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 response = await dispatch(getChannelByNameAndTeamName(teamName || currentTeamName, channelName));
const {error, data: channel} = response;
const currentChannelId = getCurrentChannelId(state);
state = getState();
const reachable = getChannelReachable(state, channelName, teamName);
if (!reachable && errorHandler) {
@@ -244,17 +276,6 @@ export function handleSelectChannelByName(channelName, teamName, errorHandler) {
}
if (channel && currentChannelId !== channel.id) {
if (channel.type === General.OPEN_CHANNEL) {
const myMemberships = getMyChannelMemberships(state);
if (!myMemberships[channel.id]) {
const currentUserId = getCurrentUserId(state);
console.log('joining channel', channel?.display_name, channel.id); //eslint-disable-line
const result = await dispatch(joinChannel(currentUserId, teamName, channel.id));
if (result.error || !result.data || !result.data.channel) {
return {error};
}
}
}
dispatch(handleSelectChannel(channel.id));
}
@@ -293,8 +314,6 @@ export function markChannelViewedAndRead(channelId, previousChannelId, markOnSer
const actions = markAsViewedAndReadBatch(state, channelId, previousChannelId, markOnServer);
dispatch(batchActions(actions, 'BATCH_MARK_CHANNEL_VIEWED_AND_READ'));
return {data: true};
};
}
@@ -327,19 +346,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,
@@ -592,87 +604,6 @@ function setLoadMorePostsVisible(visible) {
};
}
function loadGroupData() {
return async (dispatch, getState) => {
const state = getState();
const actions = [];
const team = getCurrentTeam(state);
const currentUserId = getCurrentUserId(state);
const serverVersion = state.entities.general.serverVersion;
const license = getLicense(state);
const hasLicense = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true';
if (hasLicense && team && isMinimumServerVersion(serverVersion, 5, 24)) {
for (let i = 0; i <= MAX_RETRIES; i++) {
try {
if (team.group_constrained) {
const [getAllGroupsAssociatedToChannelsInTeam, getAllGroupsAssociatedToTeam] = await Promise.all([ //eslint-disable-line no-await-in-loop
Client4.getAllGroupsAssociatedToChannelsInTeam(team.id, true),
Client4.getAllGroupsAssociatedToTeam(team.id, true),
]);
if (getAllGroupsAssociatedToChannelsInTeam.groups) {
actions.push({
type: GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNELS_IN_TEAM,
data: {groupsByChannelId: getAllGroupsAssociatedToChannelsInTeam.groups},
});
}
if (getAllGroupsAssociatedToTeam) {
actions.push({
type: GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_TEAM,
data: {...getAllGroupsAssociatedToTeam, teamID: team.id},
});
}
} else {
const [getAllGroupsAssociatedToChannelsInTeam, getGroups] = await Promise.all([ //eslint-disable-line no-await-in-loop
Client4.getAllGroupsAssociatedToChannelsInTeam(team.id, true),
Client4.getGroups(true, 0, 0),
]);
if (getAllGroupsAssociatedToChannelsInTeam.groups) {
actions.push({
type: GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNELS_IN_TEAM,
data: {groupsByChannelId: getAllGroupsAssociatedToChannelsInTeam.groups},
});
}
if (getGroups) {
actions.push({
type: GroupTypes.RECEIVED_GROUPS,
data: getGroups,
});
}
}
break;
} catch (err) {
if (i === MAX_RETRIES) {
return {error: err};
}
}
}
try {
const myGroups = await Client4.getGroupsByUserId(currentUserId);
if (myGroups.length) {
actions.push({
type: GroupTypes.RECEIVED_MY_GROUPS,
data: myGroups,
});
}
} catch {
// do nothing
}
}
if (actions.length) {
dispatch(batchActions(actions, 'BATCH_GROUP_DATA'));
}
return {data: true};
};
}
export function loadChannelsForTeam(teamId, skipDispatch = false) {
return async (dispatch, getState) => {
const state = getState();
@@ -743,15 +674,13 @@ export function loadChannelsForTeam(teamId, skipDispatch = false) {
dispatch(loadUnreadChannelPosts(data.channels, data.channelMembers));
}
dispatch(loadGroupData());
}
return {data};
};
}
export function loadSidebar(data) {
function loadSidebar(data) {
return async (dispatch, getState) => {
const state = getState();
const {channels, channelMembers} = data;
@@ -760,19 +689,5 @@ export function loadSidebar(data) {
if (sidebarActions.length) {
dispatch(batchActions(sidebarActions, 'BATCH_LOAD_SIDEBAR'));
}
return {data: true};
};
}
export function resetUnreadMessageCount(channelId) {
return async (dispatch) => {
dispatch({
type: ChannelTypes.SET_UNREAD_MSG_COUNT,
data: {
channelId,
count: 0,
},
});
};
}

View File

@@ -13,7 +13,7 @@ import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {setAppCredentials} from 'app/init/credentials';
import PushNotifications from '@init/push_notifications';
import PushNotifications from 'app/push_notifications';
import {getDeviceTimezone} from 'app/utils/timezone';
import {setCSRFFromCookie} from 'app/utils/security';
import {loadConfigAndLicense} from 'app/actions/views/root';
@@ -94,11 +94,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,
},
});
}

View File

@@ -1,51 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {intlShape} from 'react-intl';
import {Keyboard} from 'react-native';
import {showModalOverCurrentContext} from '@actions/navigation';
import {loadChannelsByTeamName} from '@actions/views/channel';
import {selectFocusedPostId} from '@mm-redux/actions/posts';
import type {DispatchFunc} from '@mm-redux/types/actions';
import {permalinkBadTeam} from '@utils/general';
import {changeOpacity} from '@utils/theme';
export let showingPermalink = false;
export function showPermalink(intl: typeof intlShape, teamName: string, postId: string, openAsPermalink = true) {
return async (dispatch: DispatchFunc) => {
const loadTeam = await dispatch(loadChannelsByTeamName(teamName, permalinkBadTeam.bind(null, intl)));
if (!loadTeam.error) {
Keyboard.dismiss();
dispatch(selectFocusedPostId(postId));
if (!showingPermalink) {
const screen = 'Permalink';
const passProps = {
isPermalink: openAsPermalink,
onClose: () => {
dispatch(closePermalink());
},
};
const options = {
layout: {
componentBackgroundColor: changeOpacity('#000', 0.2),
},
};
showingPermalink = true;
showModalOverCurrentContext(screen, passProps, options);
}
}
};
}
export function closePermalink() {
return async (dispatch: DispatchFunc) => {
showingPermalink = false;
return dispatch(selectFocusedPostId(''));
};
}

View File

@@ -4,19 +4,20 @@
import {batchActions} from 'redux-batched-actions';
import {NavigationTypes, ViewTypes} from '@constants';
import {analytics} from '@init/analytics.ts';
import {recordTime} from '@init/analytics.ts';
import {ChannelTypes, GeneralTypes, TeamTypes} from '@mm-redux/action_types';
import {fetchMyChannelsAndMembers, getChannelAndMyMember} from '@mm-redux/actions/channels';
import {fetchMyChannelsAndMembers} from '@mm-redux/actions/channels';
import {getDataRetentionPolicy} from '@mm-redux/actions/general';
import {receivedNewPost} from '@mm-redux/actions/posts';
import {getMyTeams, getMyTeamMembers} from '@mm-redux/actions/teams';
import {Client4} from '@mm-redux/client';
import {General} from '@mm-redux/constants';
import EventEmitter from '@mm-redux/utils/event_emitter';
import EphemeralStore from '@store/ephemeral_store';
import initialState from '@store/initial_state';
import {getStateForReset} from '@store/utils';
import {markAsViewedAndReadBatch} from './channel';
import {markChannelViewedAndRead} from './channel';
export function startDataCleanup() {
return async (dispatch, getState) => {
@@ -66,17 +67,17 @@ export function loadConfigAndLicense() {
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
@@ -96,22 +97,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) {
@@ -128,14 +125,17 @@ export function handleSelectTeamAndChannel(teamId, channelId) {
teamId: channel.team_id || currentTeamId,
},
});
dispatch(markChannelViewedAndRead(channelId));
}
if (actions.length) {
dispatch(batchActions(actions, 'BATCH_SELECT_TEAM_AND_CHANNEL'));
}
// eslint-disable-next-line no-console
console.log('channel switch from push notification to', channel?.display_name || channel?.id, (Date.now() - dt), 'ms');
EphemeralStore.setStartFromNotification(false);
console.log('channel switch from push notification to', channel?.display_name, (Date.now() - dt), 'ms'); //eslint-disable-line
};
}
@@ -184,7 +184,7 @@ export function recordLoadTime(screenName, category) {
return async (dispatch, getState) => {
const {currentUserId} = getState().entities.users;
analytics.recordTime(screenName, category, currentUserId);
recordTime(screenName, category, currentUserId);
};
}

View File

@@ -3,14 +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 {RequestStatus} from '@mm-redux/constants';
import {getConfig} from '@mm-redux/selectors/entities/general';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {selectFirstAvailableTeam} from '@utils/teams';
import {NavigationTypes} from 'app/constants';
import {selectFirstAvailableTeam} from 'app/utils/teams';
export function handleTeamChange(teamId) {
return async (dispatch, getState) => {
@@ -20,28 +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: {}},
], 'BATCH_SWITCH_TEAM'));
};
}

View File

@@ -8,7 +8,7 @@ import {Client4} from '@mm-redux/client';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import PushNotifications from '@init/push_notifications';
import PushNotifications from 'app/push_notifications';
const sortByNewest = (a, b) => {
if (a.create_at > b.create_at) {
@@ -56,11 +56,11 @@ export function scheduleExpiredNotification(intl) {
if (expiresAt) {
// eslint-disable-next-line no-console
console.log('Schedule Session Expiry Local Push Notification', expiresAt);
PushNotifications.scheduleNotification({
fireDate: expiresAt,
body: message,
PushNotifications.localNotificationSchedule({
date: new Date(expiresAt),
message,
userInfo: {
local: true,
localNotification: true,
},
});
}

View File

@@ -18,7 +18,6 @@ import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities
import {setAppCredentials} from 'app/init/credentials';
import {setCSRFFromCookie} from '@utils/security';
import {getDeviceTimezone} from '@utils/timezone';
import {analytics} from '@init/analytics.ts';
const HTTP_UNAUTHORIZED = 401;
@@ -40,8 +39,8 @@ export function completeLogin(user, deviceToken) {
}
// Data retention
if (config?.DataRetentionEnableMessageDeletion && config?.DataRetentionEnableMessageDeletion === 'true' &&
license?.IsLicensed === 'true' && license?.DataRetention === 'true') {
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
license.IsLicensed === 'true' && license.DataRetention === 'true') {
dispatch(getDataRetentionPolicy());
} else {
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
@@ -97,8 +96,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();
@@ -183,10 +182,14 @@ export function login(loginId, password, mfaToken, ldapOnly = false) {
};
}
export function ssoLogin() {
export function ssoLogin(token) {
return async (dispatch, getState) => {
const state = getState();
const deviceToken = state.entities?.general?.deviceToken;
Client4.setToken(token);
await setCSRFFromCookie(Client4.getUrl());
const result = await dispatch(loadMe());
if (!result.error) {

File diff suppressed because it is too large Load Diff

1181
app/actions/websocket.ts Normal file

File diff suppressed because it is too large Load Diff

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

View File

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

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 '@mm-redux/client';
import * as Actions from '@actions/websocket';
import {WebsocketEvents} from '@constants';
import TestHelper from 'test/test_helper';
import configureStore from 'test/test_store';
global.WebSocket = MockWebSocket;
describe('Websocket General Events', () => {
let store;
let mockServer;
beforeAll(async () => {
store = await configureStore();
await TestHelper.initBasic(Client4);
const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:');
mockServer = new Server(connUrl);
return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
});
afterAll(async () => {
Actions.close()();
mockServer.stop();
await TestHelper.tearDown();
});
it('handle license changed', async () => {
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.LICENSE_CHANGED, data: {license: {IsLicensed: 'true'}}}));
await TestHelper.wait(200);
const state = store.getState();
const license = state.entities.general.license;
assert.ok(license);
assert.ok(license.IsLicensed);
});
it('handle config changed', async () => {
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.CONFIG_CHANGED, data: {config: {EnableCustomEmoji: 'true', EnableLinkPreviews: 'false'}}}));
await TestHelper.wait(200);
const state = store.getState();
const config = state.entities.general.config;
assert.ok(config);
assert.ok(config.EnableCustomEmoji === 'true');
assert.ok(config.EnableLinkPreviews === 'false');
});
});

View File

@@ -1,27 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GeneralTypes} from '@mm-redux/action_types';
import {General} from '@mm-redux/constants';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {GenericAction} from '@mm-redux/types/actions';
import {WebSocketMessage} from '@mm-redux/types/websocket';
export function handleConfigChangedEvent(msg: WebSocketMessage): GenericAction {
const data = msg.data.config;
EventEmitter.emit(General.CONFIG_CHANGED, data);
return {
type: GeneralTypes.CLIENT_CONFIG_RECEIVED,
data,
};
}
export function handleLicenseChangedEvent(msg: WebSocketMessage): GenericAction {
const data = msg.data.license;
return {
type: GeneralTypes.CLIENT_LICENSE_RECEIVED,
data,
};
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GroupTypes} from '@mm-redux/action_types';
import {ActionResult, DispatchFunc, batchActions} from '@mm-redux/types/actions';
import {WebSocketMessage} from '@mm-redux/types/websocket';
export function handleGroupUpdatedEvent(msg: WebSocketMessage) {
return (dispatch: DispatchFunc) : ActionResult => {
const data = JSON.parse(msg.data.group);
dispatch(batchActions([
{
type: GroupTypes.RECEIVED_GROUP,
data,
},
{
type: GroupTypes.RECEIVED_MY_GROUPS,
data: [data],
},
]));
return {data: true};
};
}

View File

@@ -1,407 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {loadChannelsForTeam} from '@actions/views/channel';
import {getPostsSince} from '@actions/views/post';
import {loadMe} from '@actions/views/user';
import {WebsocketEvents} from '@constants';
import {ChannelTypes, GeneralTypes, PreferenceTypes, TeamTypes, UserTypes, RoleTypes} from '@mm-redux/action_types';
import {getProfilesByIds, getStatusesByIds} from '@mm-redux/actions/users';
import {Client4} from '@mm-redux/client';
import {General} from '@mm-redux/constants';
import {getCurrentChannelId, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {getCurrentUserId, getUsers, getUserStatuses} from '@mm-redux/selectors/entities/users';
import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions';
import {Channel, ChannelMembership} from '@mm-redux/types/channels';
import {GlobalState} from '@mm-redux/types/store';
import {TeamMembership} from '@mm-redux/types/teams';
import {WebSocketMessage} from '@mm-redux/types/websocket';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {removeUserFromList} from '@mm-redux/utils/user_utils';
import websocketClient from '@websocket';
import {
handleChannelConvertedEvent,
handleChannelCreatedEvent,
handleChannelDeletedEvent,
handleChannelMemberUpdatedEvent,
handleChannelSchemeUpdatedEvent,
handleChannelUnarchiveEvent,
handleChannelUpdatedEvent,
handleChannelViewedEvent,
handleDirectAddedEvent,
handleUpdateMemberRoleEvent,
} from './channels';
import {handleConfigChangedEvent, handleLicenseChangedEvent} from './general';
import {handleGroupUpdatedEvent} from './groups';
import {handleOpenDialogEvent} from './integrations';
import {handleNewPostEvent, handlePostDeleted, handlePostEdited, handlePostUnread} from './posts';
import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePreferencesDeletedEvent} from './preferences';
import {handleAddEmoji, handleReactionAddedEvent, handleReactionRemovedEvent} from './reactions';
import {handleRoleAddedEvent, handleRoleRemovedEvent, handleRoleUpdatedEvent} from './roles';
import {handleLeaveTeamEvent, handleUpdateTeamEvent, handleTeamAddedEvent} from './teams';
import {handleStatusChangedEvent, handleUserAddedEvent, handleUserRemovedEvent, handleUserRoleUpdated, handleUserUpdatedEvent} from './users';
import {getChannelSinceValue} from '@utils/channels';
import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts';
export function init(additionalOptions: any = {}) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const config = getConfig(getState());
let connUrl = additionalOptions.websocketUrl || config.WebsocketURL || Client4.getUrl();
const authToken = Client4.getToken();
connUrl += `${Client4.getUrlVersion()}/websocket`;
websocketClient.setFirstConnectCallback(() => dispatch(handleFirstConnect()));
websocketClient.setEventCallback((evt: WebSocketMessage) => dispatch(handleEvent(evt)));
websocketClient.setReconnectCallback(() => dispatch(handleReconnect()));
websocketClient.setCloseCallback((connectFailCount: number) => dispatch(handleClose(connectFailCount)));
const websocketOpts = {
connectionUrl: connUrl,
...additionalOptions,
};
return websocketClient.initialize(authToken, websocketOpts);
};
}
let reconnect = false;
export function close(shouldReconnect = false): GenericAction {
reconnect = shouldReconnect;
websocketClient.close(true);
return {
type: GeneralTypes.WEBSOCKET_CLOSED,
timestamp: Date.now(),
data: null,
};
}
export function doFirstConnect(now: number) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
const state = getState();
const {lastDisconnectAt} = state.websocket;
const actions: Array<GenericAction> = [{
type: GeneralTypes.WEBSOCKET_SUCCESS,
timestamp: now,
data: null,
}];
if (isMinimumServerVersion(Client4.getServerVersion(), 5, 14) && lastDisconnectAt) {
const currentUserId = getCurrentUserId(state);
const users = getUsers(state);
const userIds = Object.keys(users);
const userUpdates = await Client4.getProfilesByIds(userIds, {since: lastDisconnectAt});
if (userUpdates.length) {
removeUserFromList(currentUserId, userUpdates);
actions.push({
type: UserTypes.RECEIVED_PROFILES_LIST,
data: userUpdates,
});
}
}
dispatch(batchActions(actions, 'BATCH_WS_CONNCET'));
return {data: true};
};
}
export function doReconnect(now: number) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
const state = getState();
const currentTeamId = getCurrentTeamId(state);
const currentChannelId = getCurrentChannelId(state);
const currentUserId = getCurrentUserId(state);
const users = getUsers(state);
const {lastDisconnectAt} = state.websocket;
const actions: Array<GenericAction> = [];
dispatch({
type: GeneralTypes.WEBSOCKET_SUCCESS,
timestamp: now,
data: null,
});
try {
const {data: me}: any = await dispatch(loadMe(null, null, true));
if (!me.error) {
const roles = [];
if (me.roles?.length) {
roles.push(...me.roles);
}
actions.push({
type: PreferenceTypes.RECEIVED_ALL_PREFERENCES,
data: me.preferences,
}, {
type: TeamTypes.RECEIVED_MY_TEAM_UNREADS,
data: me.teamUnreads,
}, {
type: TeamTypes.RECEIVED_TEAMS_LIST,
data: me.teams,
}, {
type: TeamTypes.RECEIVED_MY_TEAM_MEMBERS,
data: me.teamMembers,
});
const currentTeamMembership = me.teamMembers.find((tm: TeamMembership) => tm.team_id === currentTeamId && tm.delete_at === 0);
if (currentTeamMembership) {
const {data: myData}: any = await dispatch(loadChannelsForTeam(currentTeamId, true));
if (myData?.channels && myData?.channelMembers) {
actions.push({
type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS,
data: myData,
});
const stillMemberOfCurrentChannel = myData.channelMembers.find((cm: ChannelMembership) => cm.channel_id === currentChannelId);
const channelStillExists = myData.channels.find((c: Channel) => c.id === currentChannelId);
const config = me.config || getConfig(getState());
const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true';
if (!stillMemberOfCurrentChannel || !channelStillExists || (!viewArchivedChannels && channelStillExists.delete_at !== 0)) {
EventEmitter.emit(General.SWITCH_TO_DEFAULT_CHANNEL, currentTeamId);
} else {
const postIds = getPostIdsInChannel(state, currentChannelId);
const since = getChannelSinceValue(state, currentChannelId, postIds);
dispatch(getPostsSince(currentChannelId, since));
}
}
if (myData.roles?.length) {
roles.push(...myData.roles);
}
} else {
// If the user is no longer a member of this team when reconnecting
const newMsg = {
data: {
user_id: currentUserId,
team_id: currentTeamId,
},
};
dispatch(handleLeaveTeamEvent(newMsg));
}
if (roles.length) {
actions.push({
type: RoleTypes.RECEIVED_ROLES,
data: roles,
});
}
if (isMinimumServerVersion(Client4.getServerVersion(), 5, 14) && lastDisconnectAt) {
const userIds = Object.keys(users);
const userUpdates = await Client4.getProfilesByIds(userIds, {since: lastDisconnectAt});
if (userUpdates.length) {
removeUserFromList(currentUserId, userUpdates);
actions.push({
type: UserTypes.RECEIVED_PROFILES_LIST,
data: userUpdates,
});
}
}
if (actions.length) {
dispatch(batchActions(actions, 'BATCH_WS_RECONNECT'));
}
}
} catch (e) {
// do nothing
}
return {data: true};
};
}
export function handleUserTypingEvent(msg: WebSocketMessage) {
return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => {
const state = getState();
const currentChannelId = getCurrentChannelId(state);
if (currentChannelId === msg.broadcast.channel_id) {
const profiles = getUsers(state);
const statuses = getUserStatuses(state);
const currentUserId = getCurrentUserId(state);
const config = getConfig(state);
const userId = msg.data.user_id;
const data = {
id: msg.broadcast.channel_id + msg.data.parent_id,
userId,
now: Date.now(),
};
dispatch({
type: WebsocketEvents.TYPING,
data,
});
setTimeout(() => {
const newState = getState();
const {typing} = newState.entities;
if (typing && typing[data.id]) {
dispatch({
type: WebsocketEvents.STOP_TYPING,
data,
});
}
}, parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds!, 10));
if (!profiles[userId] && userId !== currentUserId) {
dispatch(getProfilesByIds([userId]));
}
const status = statuses[userId];
if (status !== General.ONLINE) {
dispatch(getStatusesByIds([userId]));
}
}
return {data: true};
};
}
function handleFirstConnect() {
return (dispatch: DispatchFunc) => {
const now = Date.now();
if (reconnect) {
reconnect = false;
return dispatch(doReconnect(now));
}
return dispatch(doFirstConnect(now));
};
}
function handleReconnect() {
return (dispatch: DispatchFunc) => {
return dispatch(doReconnect(Date.now()));
};
}
function handleClose(connectFailCount: number) {
return {
type: GeneralTypes.WEBSOCKET_FAILURE,
error: connectFailCount,
data: null,
timestamp: Date.now(),
};
}
function handleEvent(msg: WebSocketMessage) {
return (dispatch: DispatchFunc) => {
switch (msg.event) {
case WebsocketEvents.POSTED:
case WebsocketEvents.EPHEMERAL_MESSAGE:
return dispatch(handleNewPostEvent(msg));
case WebsocketEvents.POST_EDITED:
return dispatch(handlePostEdited(msg));
case WebsocketEvents.POST_DELETED:
return dispatch(handlePostDeleted(msg));
case WebsocketEvents.POST_UNREAD:
return dispatch(handlePostUnread(msg));
case WebsocketEvents.LEAVE_TEAM:
return dispatch(handleLeaveTeamEvent(msg));
case WebsocketEvents.UPDATE_TEAM:
return dispatch(handleUpdateTeamEvent(msg));
case WebsocketEvents.ADDED_TO_TEAM:
return dispatch(handleTeamAddedEvent(msg));
case WebsocketEvents.USER_ADDED:
return dispatch(handleUserAddedEvent(msg));
case WebsocketEvents.USER_REMOVED:
return dispatch(handleUserRemovedEvent(msg));
case WebsocketEvents.USER_UPDATED:
return dispatch(handleUserUpdatedEvent(msg));
case WebsocketEvents.ROLE_ADDED:
return dispatch(handleRoleAddedEvent(msg));
case WebsocketEvents.ROLE_REMOVED:
return dispatch(handleRoleRemovedEvent(msg));
case WebsocketEvents.ROLE_UPDATED:
return dispatch(handleRoleUpdatedEvent(msg));
case WebsocketEvents.USER_ROLE_UPDATED:
return dispatch(handleUserRoleUpdated(msg));
case WebsocketEvents.MEMBERROLE_UPDATED:
return dispatch(handleUpdateMemberRoleEvent(msg));
case WebsocketEvents.CHANNEL_CREATED:
return dispatch(handleChannelCreatedEvent(msg));
case WebsocketEvents.CHANNEL_DELETED:
return dispatch(handleChannelDeletedEvent(msg));
case WebsocketEvents.CHANNEL_UNARCHIVED:
return dispatch(handleChannelUnarchiveEvent(msg));
case WebsocketEvents.CHANNEL_UPDATED:
return dispatch(handleChannelUpdatedEvent(msg));
case WebsocketEvents.CHANNEL_CONVERTED:
return dispatch(handleChannelConvertedEvent(msg));
case WebsocketEvents.CHANNEL_VIEWED:
return dispatch(handleChannelViewedEvent(msg));
case WebsocketEvents.CHANNEL_MEMBER_UPDATED:
return dispatch(handleChannelMemberUpdatedEvent(msg));
case WebsocketEvents.CHANNEL_SCHEME_UPDATED:
return dispatch(handleChannelSchemeUpdatedEvent(msg));
case WebsocketEvents.DIRECT_ADDED:
return dispatch(handleDirectAddedEvent(msg));
case WebsocketEvents.PREFERENCE_CHANGED:
return dispatch(handlePreferenceChangedEvent(msg));
case WebsocketEvents.PREFERENCES_CHANGED:
return dispatch(handlePreferencesChangedEvent(msg));
case WebsocketEvents.PREFERENCES_DELETED:
return dispatch(handlePreferencesDeletedEvent(msg));
case WebsocketEvents.STATUS_CHANGED:
return dispatch(handleStatusChangedEvent(msg));
case WebsocketEvents.TYPING:
return dispatch(handleUserTypingEvent(msg));
case WebsocketEvents.HELLO:
handleHelloEvent(msg);
break;
case WebsocketEvents.REACTION_ADDED:
return dispatch(handleReactionAddedEvent(msg));
case WebsocketEvents.REACTION_REMOVED:
return dispatch(handleReactionRemovedEvent(msg));
case WebsocketEvents.EMOJI_ADDED:
return dispatch(handleAddEmoji(msg));
case WebsocketEvents.LICENSE_CHANGED:
return dispatch(handleLicenseChangedEvent(msg));
case WebsocketEvents.CONFIG_CHANGED:
return dispatch(handleConfigChangedEvent(msg));
case WebsocketEvents.OPEN_DIALOG:
return dispatch(handleOpenDialogEvent(msg));
case WebsocketEvents.RECEIVED_GROUP:
return dispatch(handleGroupUpdatedEvent(msg));
}
return {data: true};
};
}
function handleHelloEvent(msg: WebSocketMessage) {
const serverVersion = msg.data.server_version;
if (serverVersion && Client4.serverVersion !== serverVersion) {
Client4.serverVersion = serverVersion;
EventEmitter.emit(General.SERVER_VERSION_CHANGED, serverVersion);
}
}
// Helpers
let lastTimeTypingSent = 0;
export function userTyping(state: GlobalState, channelId: string, parentPostId: string): void {
const config = getConfig(state);
const t = Date.now();
const stats = getCurrentChannelStats(state);
const membersInChannel = stats ? stats.member_count : 0;
if (((t - lastTimeTypingSent) > parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds!, 10)) &&
(membersInChannel < parseInt(config.MaxNotificationsPerChannel!, 10)) && (config.EnableUserTypingMessages === 'true')) {
websocketClient.userTyping(channelId, parentPostId);
lastTimeTypingSent = t;
}
}

View File

@@ -1,47 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import assert from 'assert';
import {Server, WebSocket as MockWebSocket} from 'mock-socket';
import {Client4} from '@mm-redux/client';
import * as Actions from '@actions/websocket';
import {WebsocketEvents} from '@constants';
import TestHelper from 'test/test_helper';
import configureStore from 'test/test_store';
global.WebSocket = MockWebSocket;
describe('Websocket Integration Events', () => {
let store;
let mockServer;
beforeAll(async () => {
store = await configureStore();
await TestHelper.initBasic(Client4);
const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:');
mockServer = new Server(connUrl);
return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
});
afterAll(async () => {
Actions.close()();
mockServer.stop();
await TestHelper.tearDown();
});
it('handle open dialog', async () => {
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.OPEN_DIALOG, data: {dialog: JSON.stringify({url: 'someurl', trigger_id: 'sometriggerid', dialog: {}})}}));
await TestHelper.wait(200);
const state = store.getState();
const dialog = state.entities.integrations.dialog;
assert.ok(dialog);
assert.ok(dialog.url === 'someurl');
assert.ok(dialog.trigger_id === 'sometriggerid');
assert.ok(dialog.dialog);
});
});

View File

@@ -1,14 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IntegrationTypes} from '@mm-redux/action_types';
import {ActionResult, DispatchFunc} from '@mm-redux/types/actions';
import {WebSocketMessage} from '@mm-redux/types/websocket';
export function handleOpenDialogEvent(msg: WebSocketMessage) {
return (dispatch: DispatchFunc): ActionResult => {
const data = (msg.data && msg.data.dialog) || {};
dispatch({type: IntegrationTypes.RECEIVED_DIALOG, data: JSON.parse(data)});
return {data: true};
};
}

View File

@@ -1,265 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-import-assign */
import assert from 'assert';
import nock from 'nock';
import {Server, WebSocket as MockWebSocket} from 'mock-socket';
import * as ChannelActions from '@mm-redux/actions/channels';
import * as PostActions from '@mm-redux/actions/posts';
import {Client4} from '@mm-redux/client';
import {General, Posts} from '@mm-redux/constants';
import * as PostSelectors from '@mm-redux/selectors/entities/posts';
import EventEmitter from '@mm-redux/utils/event_emitter';
import * as Actions from '@actions/websocket';
import {WebsocketEvents} from '@constants';
import TestHelper from 'test/test_helper';
import configureStore from 'test/test_store';
global.WebSocket = MockWebSocket;
describe('Websocket Post Events', () => {
let store;
let mockServer;
beforeAll(async () => {
store = await configureStore();
await TestHelper.initBasic(Client4);
const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:');
mockServer = new Server(connUrl);
return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
});
afterAll(async () => {
Actions.close()();
mockServer.stop();
await TestHelper.tearDown();
});
it('Websocket Handle New Post if post does not exist', async () => {
PostSelectors.getPost = jest.fn();
const channelId = TestHelper.basicChannel.id;
const message = JSON.stringify({event: WebsocketEvents.POSTED, data: {channel_display_name: TestHelper.basicChannel.display_name, channel_name: TestHelper.basicChannel.name, channel_type: 'O', post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w", "create_at": 1508245311774, "update_at": 1508245311774, "edit_at": 0, "delete_at": 0, "is_pinned": false, "user_id": "${TestHelper.basicUser.id}", "channel_id": "${channelId}", "root_id": "", "parent_id": "", "original_id": "", "message": "Unit Test", "type": "", "props": {}, "hashtags": "", "pending_post_id": "t36kso9nwtdhbm8dbkd6g4eeby: 1508245311749"}`, sender_name: TestHelper.basicUser.username, team_id: TestHelper.basicTeam.id}, broadcast: {omit_users: null, user_id: '', channel_id: channelId, team_id: ''}, seq: 2});
nock(Client4.getBaseRoute()).
post('/users/ids').
reply(200, [TestHelper.basicUser.id]);
nock(Client4.getBaseRoute()).
post('/users/status/ids').
reply(200, [{user_id: TestHelper.basicUser.id, status: 'online', manual: false, last_activity_at: 1507662212199}]);
// Mock that post already exists and check it is not added
PostSelectors.getPost.mockReturnValueOnce(true);
mockServer.emit('message', message);
let entities = store.getState().entities;
let posts = entities.posts.posts;
assert.deepEqual(posts, {});
// Mock that post does not exist and check it is added
PostSelectors.getPost.mockReturnValueOnce(false);
mockServer.emit('message', message);
await TestHelper.wait(100);
entities = store.getState().entities;
posts = entities.posts.posts;
const postId = Object.keys(posts)[0];
assert.ok(posts[postId].message.indexOf('Unit Test') > -1);
entities = store.getState().entities;
});
it('Websocket Handle New Post emits INCREASE_POST_VISIBILITY_BY_ONE for current channel when post does not exist', async () => {
PostSelectors.getPost = jest.fn();
const emit = jest.spyOn(EventEmitter, 'emit');
const currentChannelId = TestHelper.generateId();
const otherChannelId = TestHelper.generateId();
const messageFor = (channelId) => ({event: WebsocketEvents.POSTED, data: {channel_display_name: TestHelper.basicChannel.display_name, channel_name: TestHelper.basicChannel.name, channel_type: 'O', post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w", "create_at": 1508245311774, "update_at": 1508245311774, "edit_at": 0, "delete_at": 0, "is_pinned": false, "user_id": "${TestHelper.basicUser.id}", "channel_id": "${channelId}", "root_id": "", "parent_id": "", "original_id": "", "message": "Unit Test", "type": "", "props": {}, "hashtags": "", "pending_post_id": "t36kso9nwtdhbm8dbkd6g4eeby: 1508245311749"}`, sender_name: TestHelper.basicUser.username, team_id: TestHelper.basicTeam.id}, broadcast: {omit_users: null, user_id: '', channel_id: channelId, team_id: ''}, seq: 2});
await store.dispatch(ChannelActions.selectChannel(currentChannelId));
await TestHelper.wait(100);
// Post does not exist and is not for current channel
PostSelectors.getPost.mockReturnValueOnce(false);
mockServer.emit('message', JSON.stringify(messageFor(otherChannelId)));
expect(emit).not.toHaveBeenCalled();
// Post exists and is not for current channel
PostSelectors.getPost.mockReturnValueOnce(true);
mockServer.emit('message', JSON.stringify(messageFor(otherChannelId)));
expect(emit).not.toHaveBeenCalled();
// Post exists and is for current channel
PostSelectors.getPost.mockReturnValueOnce(true);
mockServer.emit('message', JSON.stringify(messageFor(currentChannelId)));
expect(emit).not.toHaveBeenCalled();
// Post does not exist and is for current channel
PostSelectors.getPost.mockReturnValueOnce(false);
mockServer.emit('message', JSON.stringify(messageFor(currentChannelId)));
expect(emit).toHaveBeenCalledWith(WebsocketEvents.INCREASE_POST_VISIBILITY_BY_ONE);
});
it('Websocket Handle New Post if status is manually set do not set to online', async () => {
const userId = TestHelper.generateId();
store = await configureStore({
entities: {
users: {
statuses: {
[userId]: General.DND,
},
isManualStatus: {
[userId]: true,
},
},
},
});
await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
const channelId = TestHelper.basicChannel.id;
const message = JSON.stringify({
event: WebsocketEvents.POSTED,
data: {
channel_display_name: TestHelper.basicChannel.display_name,
channel_name: TestHelper.basicChannel.name,
channel_type: 'O',
post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w", "create_at": 1508245311774, "update_at": 1508245311774, "edit_at": 0, "delete_at": 0, "is_pinned": false, "user_id": "${userId}", "channel_id": "${channelId}", "root_id": "", "parent_id": "", "original_id": "", "message": "Unit Test", "type": "", "props": {}, "hashtags": "", "pending_post_id": "t36kso9nwtdhbm8dbkd6g4eeby: 1508245311749"}`,
sender_name: TestHelper.basicUser.username,
team_id: TestHelper.basicTeam.id,
},
broadcast: {
omit_users: null,
user_id: userId,
channel_id: channelId,
team_id: '',
},
seq: 2,
});
mockServer.emit('message', message);
const entities = store.getState().entities;
const statuses = entities.users.statuses;
assert.equal(statuses[userId], General.DND);
});
it('Websocket Handle Post Edited', async () => {
const post = {id: '71k8gz5ompbpfkrzaxzodffj8w'};
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.POST_EDITED, data: {post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w","create_at": 1508245311774,"update_at": 1585236976007,"edit_at": 1585236976007,"delete_at": 0,"is_pinned": false,"user_id": "${TestHelper.basicUser.id}","channel_id": "${TestHelper.basicChannel.id}","root_id": "","parent_id": "","original_id": "","message": "Unit Test (edited)","type": "","props": {},"hashtags": "","pending_post_id": ""}`}, broadcast: {omit_users: null, user_id: '', channel_id: '18k9ffsuci8xxm7ak68zfdyrce', team_id: ''}, seq: 2}));
await TestHelper.wait(300);
const {posts} = store.getState().entities.posts;
assert.ok(posts);
assert.ok(posts[post.id]);
assert.ok(posts[post.id].message.indexOf('(edited)') > -1);
});
it('Websocket Handle Post Deleted', async () => {
const post = TestHelper.fakePost();
post.channel_id = TestHelper.basicChannel.id;
post.id = '71k8gz5ompbpfkrzaxzodffj8w';
store.dispatch(PostActions.receivedPost(post));
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.POST_DELETED, data: {post: `{"id": "71k8gz5ompbpfkrzaxzodffj8w","create_at": 1508245311774,"update_at": 1508247709215,"edit_at": 1508247709215,"delete_at": 0,"is_pinned": false,"user_id": "${TestHelper.basicUser.id}","channel_id": "${post.channel_id}","root_id": "","parent_id": "","original_id": "","message": "Unit Test","type": "","props": {},"hashtags": "","pending_post_id": ""}`}, broadcast: {omit_users: null, user_id: '', channel_id: '18k9ffsuci8xxm7ak68zfdyrce', team_id: ''}, seq: 7}));
const entities = store.getState().entities;
const {posts} = entities.posts;
assert.strictEqual(posts[post.id].state, Posts.POST_DELETED);
});
it('Websocket handle Post Unread', async () => {
const teamId = TestHelper.generateId();
const channelId = TestHelper.generateId();
const userId = TestHelper.generateId();
store = await configureStore({
entities: {
channels: {
channels: {
[channelId]: {id: channelId},
},
myMembers: {
[channelId]: {msg_count: 10, mention_count: 0, last_viewed_at: 0},
},
},
teams: {
myMembers: {
[teamId]: {msg_count: 10, mention_count: 0},
},
},
},
});
await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
mockServer.emit('message', JSON.stringify({
event: WebsocketEvents.POST_UNREAD,
data: {
last_viewed_at: 25,
msg_count: 3,
mention_count: 2,
delta_msg: 7,
},
broadcast: {omit_users: null, user_id: userId, channel_id: channelId, team_id: teamId},
seq: 7,
}));
const state = store.getState();
assert.equal(state.entities.channels.manuallyUnread[channelId], true);
assert.equal(state.entities.channels.myMembers[channelId].msg_count, 3);
assert.equal(state.entities.channels.myMembers[channelId].mention_count, 2);
assert.equal(state.entities.channels.myMembers[channelId].last_viewed_at, 25);
assert.equal(state.entities.teams.myMembers[teamId].msg_count, 3);
assert.equal(state.entities.teams.myMembers[teamId].mention_count, 2);
});
it('Websocket handle Post Unread When marked on the same client', async () => {
const teamId = TestHelper.generateId();
const channelId = TestHelper.generateId();
const userId = TestHelper.generateId();
store = await configureStore({
entities: {
channels: {
channels: {
[channelId]: {id: channelId},
},
myMembers: {
[channelId]: {msg_count: 5, mention_count: 4, last_viewed_at: 14},
},
manuallyUnread: {
[channelId]: true,
},
},
teams: {
myMembers: {
[teamId]: {msg_count: 5, mention_count: 4},
},
},
},
});
await store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
mockServer.emit('message', JSON.stringify({
event: WebsocketEvents.POST_UNREAD,
data: {
last_viewed_at: 25,
msg_count: 5,
mention_count: 4,
delta_msg: 1,
},
broadcast: {omit_users: null, user_id: userId, channel_id: channelId, team_id: teamId},
seq: 17,
}));
const state = store.getState();
assert.equal(state.entities.channels.manuallyUnread[channelId], true);
assert.equal(state.entities.channels.myMembers[channelId].msg_count, 5);
assert.equal(state.entities.channels.myMembers[channelId].mention_count, 4);
assert.equal(state.entities.channels.myMembers[channelId].last_viewed_at, 14);
assert.equal(state.entities.teams.myMembers[teamId].msg_count, 5);
assert.equal(state.entities.teams.myMembers[teamId].mention_count, 4);
});
});

View File

@@ -1,209 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
fetchMyChannel,
fetchMyChannelMember,
makeDirectChannelVisibleIfNecessary,
makeGroupMessageVisibleIfNecessary,
markChannelAsUnread,
} from '@actions/helpers/channels';
import {markAsViewedAndReadBatch} from '@actions/views/channel';
import {getPostsAdditionalDataBatch, getPostThread} from '@actions/views/post';
import {WebsocketEvents} from '@constants';
import {ChannelTypes} from '@mm-redux/action_types';
import {getUnreadPostData, postDeleted, receivedNewPost, receivedPost} from '@mm-redux/actions/posts';
import {General} from '@mm-redux/constants';
import {
getChannel,
getCurrentChannelId,
getMyChannelMember as selectMyChannelMember,
isManuallyUnread,
} from '@mm-redux/selectors/entities/channels';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {getPost as selectPost} from '@mm-redux/selectors/entities/posts';
import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {isFromWebhook, isSystemMessage, shouldIgnorePost} from '@mm-redux/utils/post_utils';
import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions';
import {WebSocketMessage} from '@mm-redux/types/websocket';
export function handleNewPostEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
const state = getState();
const currentChannelId = getCurrentChannelId(state);
const currentUserId = getCurrentUserId(state);
const data = JSON.parse(msg.data.post);
const post = {
...data,
ownPost: data.user_id === currentUserId,
};
const actions: Array<GenericAction> = [];
const exists = selectPost(state, post.pending_post_id);
if (!exists) {
if (getCurrentChannelId(state) === post.channel_id) {
EventEmitter.emit(WebsocketEvents.INCREASE_POST_VISIBILITY_BY_ONE);
}
const myChannel = getChannel(state, post.channel_id);
if (!myChannel) {
const channel = await fetchMyChannel(post.channel_id);
if (channel.data) {
actions.push({
type: ChannelTypes.RECEIVED_CHANNEL,
data: channel.data,
});
}
}
const myChannelMember = selectMyChannelMember(state, post.channel_id);
if (!myChannelMember) {
const member = await fetchMyChannelMember(post.channel_id);
if (member.data) {
actions.push({
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
data: member.data,
});
}
}
actions.push(receivedNewPost(post));
// If we don't have the thread for this post, fetch it from the server
// and include the actions in the batch
if (post.root_id) {
const rootPost = selectPost(state, post.root_id);
if (!rootPost) {
const thread: any = await dispatch(getPostThread(post.root_id, true));
if (thread.data?.length) {
actions.push(...thread.data);
}
}
}
if (post.channel_id === currentChannelId) {
const id = post.channel_id + post.root_id;
const {typing} = state.entities;
if (typing[id]) {
actions.push({
type: WebsocketEvents.STOP_TYPING,
data: {
id,
userId: post.user_id,
now: Date.now(),
},
});
}
}
// Fetch and batch additional post data
const additional: any = await dispatch(getPostsAdditionalDataBatch([post]));
if (additional.data.length) {
actions.push(...additional.data);
}
if (msg.data.channel_type === General.DM_CHANNEL) {
const otherUserId = getUserIdFromChannelName(currentUserId, msg.data.channel_name);
const dmAction = makeDirectChannelVisibleIfNecessary(state, otherUserId);
if (dmAction) {
actions.push(dmAction);
}
} else if (msg.data.channel_type === General.GM_CHANNEL) {
const gmActions = await makeGroupMessageVisibleIfNecessary(state, post.channel_id);
if (gmActions) {
actions.push(...gmActions);
}
}
if (!shouldIgnorePost(post)) {
let markAsRead = false;
let markAsReadOnServer = false;
if (!isManuallyUnread(state, post.channel_id)) {
if (
post.user_id === getCurrentUserId(state) &&
!isSystemMessage(post) &&
!isFromWebhook(post)
) {
markAsRead = true;
markAsReadOnServer = false;
} else if (post.channel_id === currentChannelId) {
markAsRead = true;
markAsReadOnServer = true;
}
}
if (markAsRead) {
const readActions = markAsViewedAndReadBatch(state, post.channel_id, undefined, markAsReadOnServer);
actions.push(...readActions);
} else {
const unreadActions = markChannelAsUnread(state, msg.data.team_id, post.channel_id, msg.data.mentions);
actions.push(...unreadActions);
}
}
dispatch(batchActions(actions, 'BATCH_WS_NEW_POST'));
}
return {data: true};
};
}
export function handlePostEdited(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const data = JSON.parse(msg.data.post);
const post = {
...data,
ownPost: data.user_id === currentUserId,
};
const actions = [receivedPost(post)];
const additional: any = await dispatch(getPostsAdditionalDataBatch([post]));
if (additional.data.length) {
actions.push(...additional.data);
}
dispatch(batchActions(actions, 'BATCH_WS_POST_EDITED'));
return {data: true};
};
}
export function handlePostDeleted(msg: WebSocketMessage): GenericAction {
const data = JSON.parse(msg.data.post);
return postDeleted(data);
}
export function handlePostUnread(msg: WebSocketMessage) {
return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => {
const state = getState();
const manual = isManuallyUnread(state, msg.broadcast.channel_id);
if (!manual) {
const member = selectMyChannelMember(state, msg.broadcast.channel_id);
const delta = member ? member.msg_count - msg.data.msg_count : msg.data.msg_count;
const info = {
...msg.data,
user_id: msg.broadcast.user_id,
team_id: msg.broadcast.team_id,
channel_id: msg.broadcast.channel_id,
deltaMsgs: delta,
};
const data = getUnreadPostData(info, state);
dispatch({
type: ChannelTypes.POST_UNREAD_SUCCESS,
data,
});
return {data};
}
return {data: null};
};
}

View File

@@ -1,60 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getAddedDmUsersIfNecessary} from '@actions/helpers/channels';
import {getPost} from '@actions/views/post';
import {PreferenceTypes} from '@mm-redux/action_types';
import {Preferences} from '@mm-redux/constants';
import {getAllPosts} from '@mm-redux/selectors/entities/posts';
import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions';
import {PreferenceType} from '@mm-redux/types/preferences';
import {WebSocketMessage} from '@mm-redux/types/websocket';
export function handlePreferenceChangedEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
const preference = JSON.parse(msg.data.preference);
const actions: Array<GenericAction> = [{
type: PreferenceTypes.RECEIVED_PREFERENCES,
data: [preference],
}];
const dmActions = await getAddedDmUsersIfNecessary(getState(), [preference]);
if (dmActions.length) {
actions.push(...dmActions);
}
dispatch(batchActions(actions, 'BATCH_WS_PREFERENCE_CHANGED'));
return {data: true};
};
}
export function handlePreferencesChangedEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
const preferences: PreferenceType[] = JSON.parse(msg.data.preferences);
const posts = getAllPosts(getState());
const actions: Array<GenericAction> = [{
type: PreferenceTypes.RECEIVED_PREFERENCES,
data: preferences,
}];
preferences.forEach((pref) => {
if (pref.category === Preferences.CATEGORY_FLAGGED_POST && !posts[pref.name]) {
dispatch(getPost(pref.name));
}
});
const dmActions = await getAddedDmUsersIfNecessary(getState(), preferences);
if (dmActions.length) {
actions.push(...dmActions);
}
dispatch(batchActions(actions, 'BATCH_WS_PREFERENCES_CHANGED'));
return {data: true};
};
}
export function handlePreferencesDeletedEvent(msg: WebSocketMessage): GenericAction {
const preferences = JSON.parse(msg.data.preferences);
return {type: PreferenceTypes.DELETED_PREFERENCES, data: preferences};
}

View File

@@ -1,60 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import assert from 'assert';
import {Server, WebSocket as MockWebSocket} from 'mock-socket';
import {Client4} from '@mm-redux/client';
import * as Actions from '@actions/websocket';
import {WebsocketEvents} from '@constants';
import TestHelper from 'test/test_helper';
import configureStore from 'test/test_store';
global.WebSocket = MockWebSocket;
describe('Websocket Reaction Events', () => {
let store;
let mockServer;
beforeAll(async () => {
store = await configureStore();
await TestHelper.initBasic(Client4);
const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:');
mockServer = new Server(connUrl);
return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
});
afterAll(async () => {
Actions.close()();
mockServer.stop();
await TestHelper.tearDown();
});
it('Websocket Handle Reaction Added to Post', async () => {
const emoji = '+1';
const post = {id: 'w7yo9377zbfi9mgiq5gbfpn3ha'};
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.REACTION_ADDED, data: {reaction: `{"user_id":"${TestHelper.basicUser.id}","post_id":"w7yo9377zbfi9mgiq5gbfpn3ha","emoji_name":"${emoji}","create_at":1508249125852}`}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 12}));
await TestHelper.wait(300);
const nextEntities = store.getState().entities;
const {reactions} = nextEntities.posts;
const reactionsForPost = reactions[post.id];
assert.ok(reactionsForPost.hasOwnProperty(`${TestHelper.basicUser.id}-${emoji}`));
});
it('Websocket handle emoji added', async () => {
const created = {id: '1mmgakhhupfgfm8oug6pooc5no'};
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.EMOJI_ADDED, data: {emoji: `{"id":"1mmgakhhupfgfm8oug6pooc5no","create_at":1508263941321,"update_at":1508263941321,"delete_at":0,"creator_id":"t36kso9nwtdhbm8dbkd6g4eeby","name":"${TestHelper.generateId()}"}`}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 2}));
await TestHelper.wait(200);
const state = store.getState();
const emojis = state.entities.emojis.customEmoji;
assert.ok(emojis);
assert.ok(emojis[created.id]);
});
});

View File

@@ -1,41 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {EmojiTypes, PostTypes} from '@mm-redux/action_types';
import {getCustomEmojiForReaction} from '@mm-redux/actions/posts';
import {ActionResult, DispatchFunc, GenericAction} from '@mm-redux/types/actions';
import {WebSocketMessage} from '@mm-redux/types/websocket';
export function handleAddEmoji(msg: WebSocketMessage): GenericAction {
const data = JSON.parse(msg.data.emoji);
return {
type: EmojiTypes.RECEIVED_CUSTOM_EMOJI,
data,
};
}
export function handleReactionAddedEvent(msg: WebSocketMessage) {
return (dispatch: DispatchFunc): ActionResult => {
const {data} = msg;
const reaction = JSON.parse(data.reaction);
dispatch(getCustomEmojiForReaction(reaction.emoji_name));
dispatch({
type: PostTypes.RECEIVED_REACTION,
data: reaction,
});
return {data: true};
};
}
export function handleReactionRemovedEvent(msg: WebSocketMessage): GenericAction {
const {data} = msg;
const reaction = JSON.parse(data.reaction);
return {
type: PostTypes.REACTION_DELETED,
data: reaction,
};
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {RoleTypes} from '@mm-redux/action_types';
import {GenericAction} from '@mm-redux/types/actions';
import {WebSocketMessage} from '@mm-redux/types/websocket';
export function handleRoleAddedEvent(msg: WebSocketMessage): GenericAction {
const role = JSON.parse(msg.data.role);
return {
type: RoleTypes.RECEIVED_ROLE,
data: role,
};
}
export function handleRoleRemovedEvent(msg: WebSocketMessage): GenericAction {
const role = JSON.parse(msg.data.role);
return {
type: RoleTypes.ROLE_DELETED,
data: role,
};
}
export function handleRoleUpdatedEvent(msg: WebSocketMessage): GenericAction {
const role = JSON.parse(msg.data.role);
return {
type: RoleTypes.RECEIVED_ROLE,
data: role,
};
}

View File

@@ -1,106 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import assert from 'assert';
import nock from 'nock';
import {Server, WebSocket as MockWebSocket} from 'mock-socket';
import {batchActions} from 'redux-batched-actions';
import {TeamTypes, UserTypes} from '@mm-redux/action_types';
import {Client4} from '@mm-redux/client';
import * as Actions from '@actions/websocket';
import {WebsocketEvents} from '@constants';
import TestHelper from 'test/test_helper';
import configureStore from 'test/test_store';
global.WebSocket = MockWebSocket;
describe('Websocket Team Events', () => {
let store;
let mockServer;
beforeAll(async () => {
store = await configureStore();
await TestHelper.initBasic(Client4);
const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:');
mockServer = new Server(connUrl);
store.dispatch(batchActions([
{type: UserTypes.RECEIVED_ME, data: TestHelper.basicUser},
{type: TeamTypes.RECEIVED_TEAM, data: TestHelper.basicTeam},
{type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, data: TestHelper.basicTeamMember},
{type: TeamTypes.RECEIVED_MY_TEAM_UNREADS, data: [TestHelper.basicTeamMember]},
]));
return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
});
afterAll(async () => {
Actions.close()();
mockServer.stop();
await TestHelper.tearDown();
});
// If we move this test lower it will fail cause of a permissions issue
it('Websocket handle team updated', async () => {
const team = {id: '55pfercbm7bsmd11p5cjpgsbwr'};
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.UPDATE_TEAM, data: {team: `{"id":"55pfercbm7bsmd11p5cjpgsbwr","create_at":1495553950859,"update_at":1508250370054,"delete_at":0,"display_name":"${TestHelper.basicTeam.display_name}","name":"${TestHelper.basicTeam.name}","description":"description","email":"","type":"O","company_name":"","allowed_domains":"","invite_id":"m93f54fu5bfntewp8ctwonw19w","allow_open_invite":true}`}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 26}));
await TestHelper.wait(300);
const entities = store.getState().entities;
const {teams} = entities.teams;
const updated = teams[team.id];
assert.ok(updated);
assert.strictEqual(updated.allow_open_invite, true);
});
it('Websocket handle team patched', async () => {
const team = {id: '55pfercbm7bsmd11p5cjpgsbwr'};
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.UPDATE_TEAM, data: {team: `{"id":"55pfercbm7bsmd11p5cjpgsbwr","create_at":1495553950859,"update_at":1508250370054,"delete_at":0,"display_name":"${TestHelper.basicTeam.display_name}","name":"${TestHelper.basicTeam.name}","description":"description","email":"","type":"O","company_name":"","allowed_domains":"","invite_id":"m93f54fu5bfntewp8ctwonw19w","allow_open_invite":true}`}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 26}));
await TestHelper.wait(300);
const entities = store.getState().entities;
const {teams} = entities.teams;
const updated = teams[team.id];
assert.ok(updated);
assert.strictEqual(updated.allow_open_invite, true);
});
it('Websocket handle user added to team', async () => {
const team = TestHelper.basicTeam;
nock(Client4.getBaseRoute()).
get(`/teams/${team.id}`).
reply(200, team);
nock(Client4.getBaseRoute()).
get('/users/me/teams/unread').
reply(200, [{team_id: team.id, msg_count: 0, mention_count: 0}]);
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.ADDED_TO_TEAM, data: {team_id: team.id, user_id: TestHelper.basicUser.id}, broadcast: {omit_users: null, user_id: TestHelper.basicUser.id, channel_id: '', team_id: ''}, seq: 2}));
await TestHelper.wait(300);
const {teams, myMembers} = store.getState().entities.teams;
assert.ok(teams[team.id]);
assert.ok(myMembers[team.id]);
const member = myMembers[team.id];
assert.ok(member.hasOwnProperty('mention_count'));
});
it('WebSocket Leave Team', async () => {
const team = TestHelper.basicTeam;
store.dispatch(batchActions([
{type: UserTypes.RECEIVED_ME, data: TestHelper.basicUser},
{type: TeamTypes.RECEIVED_TEAM, data: TestHelper.basicTeam},
{type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, data: TestHelper.basicTeamMember},
]));
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.LEAVE_TEAM, data: {team_id: team.id, user_id: TestHelper.basicUser.id}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: team.id}, seq: 35}));
const {myMembers} = store.getState().entities.teams;
assert.ifError(myMembers[team.id]);
});
});

View File

@@ -1,106 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {RoleTypes, TeamTypes} from '@mm-redux/action_types';
import {notVisibleUsersActions} from '@mm-redux/actions/helpers';
import {Client4} from '@mm-redux/client';
import {getCurrentTeamId, getTeams as getTeamsSelector} from '@mm-redux/selectors/entities/teams';
import {getCurrentUser} from '@mm-redux/selectors/entities/users';
import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions';
import {WebSocketMessage} from '@mm-redux/types/websocket';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {isGuest} from '@mm-redux/utils/user_utils';
export function handleLeaveTeamEvent(msg: Partial<WebSocketMessage>) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
const state = getState();
const teams = getTeamsSelector(state);
const currentTeamId = getCurrentTeamId(state);
const currentUser = getCurrentUser(state);
if (currentUser.id === msg.data.user_id) {
const actions: Array<GenericAction> = [{type: TeamTypes.LEAVE_TEAM, data: teams[msg.data.team_id]}];
if (isGuest(currentUser.roles)) {
const notVisible = await notVisibleUsersActions(state);
if (notVisible.length) {
actions.push(...notVisible);
}
}
dispatch(batchActions(actions, 'BATCH_WS_LEAVE_TEAM'));
// if they are on the team being removed deselect the current team and channel
if (currentTeamId === msg.data.team_id) {
EventEmitter.emit('leave_team');
}
}
return {data: true};
};
}
export function handleUpdateTeamEvent(msg: WebSocketMessage): GenericAction {
return {
type: TeamTypes.UPDATED_TEAM,
data: JSON.parse(msg.data.team),
};
}
export function handleTeamAddedEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc): Promise<ActionResult> => {
try {
const teamId = msg.data.team_id;
const userId = msg.data.user_id;
const [team, member, teamUnreads] = await Promise.all([
Client4.getTeam(msg.data.team_id),
Client4.getTeamMember(teamId, userId),
Client4.getMyTeamUnreads(),
]);
const actions = [];
if (team) {
actions.push({
type: TeamTypes.RECEIVED_TEAM,
data: team,
});
if (member) {
actions.push({
type: TeamTypes.RECEIVED_MY_TEAM_MEMBER,
data: member,
});
if (member.roles) {
const rolesToLoad = new Set<string>();
for (const role of member.roles.split(' ')) {
rolesToLoad.add(role);
}
if (rolesToLoad.size > 0) {
const roles = await Client4.getRolesByNames(Array.from(rolesToLoad));
if (roles.length) {
actions.push({
type: RoleTypes.RECEIVED_ROLES,
data: roles,
});
}
}
}
}
if (teamUnreads) {
actions.push({
type: TeamTypes.RECEIVED_MY_TEAM_UNREADS,
data: teamUnreads,
});
}
}
if (actions.length) {
dispatch(batchActions(actions, 'BATCH_WS_TEAM_ADDED'));
}
} catch {
// do nothing
}
return {data: true};
};
}

View File

@@ -1,94 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import assert from 'assert';
import {Server, WebSocket as MockWebSocket} from 'mock-socket';
import {batchActions} from 'redux-batched-actions';
import {TeamTypes, UserTypes} from '@mm-redux/action_types';
import {Client4} from '@mm-redux/client';
import * as Actions from '@actions/websocket';
import {WebsocketEvents} from '@constants';
import TestHelper from 'test/test_helper';
import configureStore from 'test/test_store';
global.WebSocket = MockWebSocket;
describe('Websocket User Events', () => {
let store;
let mockServer;
beforeAll(async () => {
store = await configureStore();
await TestHelper.initBasic(Client4);
const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:');
mockServer = new Server(connUrl);
store.dispatch(batchActions([
{type: UserTypes.RECEIVED_ME, data: TestHelper.basicUser},
{type: TeamTypes.RECEIVED_TEAM, data: TestHelper.basicTeam},
{type: TeamTypes.RECEIVED_MY_TEAM_MEMBER, data: TestHelper.basicTeamMember},
]));
return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
});
afterAll(async () => {
Actions.close()();
mockServer.stop();
await TestHelper.tearDown();
});
it('Websocket Handle User Added', async () => {
const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()};
store.dispatch({type: UserTypes.RECEIVED_PROFILE_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}});
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_ADDED, data: {team_id: TestHelper.basicTeam.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42}));
const entities = store.getState().entities;
const profilesInChannel = entities.users.profilesInChannel;
assert.ok(profilesInChannel[TestHelper.basicChannel.id].has(user.id));
});
it('Websocket Handle User Removed', async () => {
const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()};
store.dispatch({type: UserTypes.RECEIVED_PROFILE_NOT_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}});
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_REMOVED, data: {remover_id: TestHelper.basicUser.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42}));
const state = store.getState();
const entities = state.entities;
const profilesNotInChannel = entities.users.profilesNotInChannel;
assert.ok(profilesNotInChannel[TestHelper.basicChannel.id].has(user.id));
});
it('Websocket Handle User Removed when Current is Guest', async () => {
const basicGuestUser = TestHelper.fakeUserWithId();
basicGuestUser.roles = 'system_guest';
const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()};
// add user first
store.dispatch({type: UserTypes.RECEIVED_PROFILE_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}});
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_ADDED, data: {team_id: TestHelper.basicTeam.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42}));
assert.ok(store.getState().entities.users.profilesInChannel[TestHelper.basicChannel.id].has(user.id));
// remove user
store.dispatch({type: UserTypes.RECEIVED_PROFILE_NOT_IN_CHANNEL, data: {id: TestHelper.basicChannel.id, user_id: user.id}});
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_REMOVED, data: {remover_id: basicGuestUser.id, user_id: user.id}, broadcast: {omit_users: null, user_id: '', channel_id: TestHelper.basicChannel.id, team_id: ''}, seq: 42}));
assert.ok(!store.getState().entities.users.profilesInChannel[TestHelper.basicChannel.id].has(user.id));
});
it('Websocket Handle User Updated', async () => {
const user = {...TestHelper.fakeUser(), id: TestHelper.generateId()};
mockServer.emit('message', JSON.stringify({event: WebsocketEvents.USER_UPDATED, data: {user: {id: user.id, create_at: 1495570297229, update_at: 1508253268652, delete_at: 0, username: 'tim', auth_data: '', auth_service: '', email: 'tim@bladekick.com', nickname: '', first_name: 'tester4', last_name: '', position: '', roles: 'system_user', locale: 'en'}}, broadcast: {omit_users: null, user_id: '', channel_id: '', team_id: ''}, seq: 53}));
store.subscribe(() => {
const state = store.getState();
const entities = state.entities;
const profiles = entities.users.profiles;
assert.strictEqual(profiles[user.id].first_name, 'tester4');
});
});
});

View File

@@ -1,205 +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 {getMe} from '@actions/views/user';
import {ChannelTypes, TeamTypes, UserTypes, RoleTypes} from '@mm-redux/action_types';
import {notVisibleUsersActions} from '@mm-redux/actions/helpers';
import {Client4} from '@mm-redux/client';
import {General} from '@mm-redux/constants';
import {getAllChannels, getCurrentChannelId, getChannelMembersInChannels} from '@mm-redux/selectors/entities/channels';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {getCurrentUser, getCurrentUserId} from '@mm-redux/selectors/entities/users';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions';
import {WebSocketMessage} from '@mm-redux/types/websocket';
import {isGuest} from '@mm-redux/utils/user_utils';
export function handleStatusChangedEvent(msg: WebSocketMessage): GenericAction {
return {
type: UserTypes.RECEIVED_STATUSES,
data: [{user_id: msg.data.user_id, status: msg.data.status}],
};
}
export function handleUserAddedEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
try {
const state = getState();
const currentChannelId = getCurrentChannelId(state);
const currentTeamId = getCurrentTeamId(state);
const currentUserId = getCurrentUserId(state);
const teamId = msg.data.team_id;
const actions: Array<GenericAction> = [{
type: ChannelTypes.CHANNEL_MEMBER_ADDED,
data: {
channel_id: msg.broadcast.channel_id,
user_id: msg.data.user_id,
},
}];
if (msg.broadcast.channel_id === currentChannelId) {
const stat = await Client4.getChannelStats(currentChannelId);
actions.push({
type: ChannelTypes.RECEIVED_CHANNEL_STATS,
data: stat,
});
}
if (teamId === currentTeamId && msg.data.user_id === currentUserId) {
const channelActions = await fetchChannelAndMyMember(msg.broadcast.channel_id);
if (channelActions.length) {
actions.push(...channelActions);
}
}
dispatch(batchActions(actions, 'BATCH_WS_USER_ADDED'));
} catch (error) {
//do nothing
}
return {data: true};
};
}
export function handleUserRemovedEvent(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise<ActionResult> => {
try {
const state = getState();
const channels = getAllChannels(state);
const currentChannelId = getCurrentChannelId(state);
const currentTeamId = getCurrentTeamId(state);
const currentUser = getCurrentUser(state);
const actions: Array<GenericAction> = [];
let channelId;
let userId;
if (msg.data.user_id) {
userId = msg.data.user_id;
channelId = msg.broadcast.channel_id;
} else if (msg.broadcast.user_id) {
channelId = msg.data.channel_id;
userId = msg.broadcast.user_id;
}
if (userId) {
actions.push({
type: ChannelTypes.CHANNEL_MEMBER_REMOVED,
data: {
channel_id: channelId,
user_id: userId,
},
});
}
const channel = channels[currentChannelId];
if (msg.data?.user_id !== currentUser.id) {
const members = getChannelMembersInChannels(state);
const isMember = Object.values(members).some((member) => member[msg.data.user_id]);
if (channel && isGuest(currentUser.roles) && !isMember) {
actions.push({
type: UserTypes.PROFILE_NO_LONGER_VISIBLE,
data: {user_id: msg.data.user_id},
}, {
type: TeamTypes.REMOVE_MEMBER_FROM_TEAM,
data: {team_id: channel.team_id, user_id: msg.data.user_id},
});
}
}
let redirectToDefaultChannel = false;
if (msg.broadcast.user_id === currentUser.id && currentTeamId) {
const {data: myData}: any = await dispatch(loadChannelsForTeam(currentTeamId, true));
if (myData?.channels && myData?.channelMembers) {
actions.push({
type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS,
data: myData,
});
}
if (channel) {
actions.push({
type: ChannelTypes.LEAVE_CHANNEL,
data: {
id: msg.data.channel_id,
user_id: currentUser.id,
team_id: channel.team_id,
type: channel.type,
},
});
}
if (msg.data.channel_id === currentChannelId) {
// emit the event so the client can change his own state
redirectToDefaultChannel = true;
}
if (isGuest(currentUser.roles)) {
const notVisible = await notVisibleUsersActions(state);
if (notVisible.length) {
actions.push(...notVisible);
}
}
} else if (msg.data.channel_id === currentChannelId) {
const stat = await Client4.getChannelStats(currentChannelId);
actions.push({
type: ChannelTypes.RECEIVED_CHANNEL_STATS,
data: stat,
});
}
dispatch(batchActions(actions, 'BATCH_WS_USER_REMOVED'));
if (redirectToDefaultChannel) {
EventEmitter.emit(General.REMOVED_FROM_CHANNEL, channel.display_name);
EventEmitter.emit(General.SWITCH_TO_DEFAULT_CHANNEL, currentTeamId);
}
} catch {
// do nothing
}
return {data: true};
};
}
export function handleUserRoleUpdated(msg: WebSocketMessage) {
return async (dispatch: DispatchFunc): Promise<ActionResult> => {
try {
const roles = msg.data.roles.split(' ');
const data = await Client4.getRolesByNames(roles);
dispatch({
type: RoleTypes.RECEIVED_ROLES,
data: data.roles,
});
} catch {
// do nothing
}
return {data: true};
};
}
export function handleUserUpdatedEvent(msg: WebSocketMessage) {
return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => {
const currentUser = getCurrentUser(getState());
const user = msg.data.user;
if (user.id === currentUser.id) {
if (user.update_at > currentUser.update_at) {
// Need to request me to make sure we don't override with sanitized fields from the
// websocket event
dispatch(getMe());
}
} else {
dispatch({
type: UserTypes.RECEIVED_PROFILES,
data: {
[user.id]: user,
},
});
}
return {data: true};
};
}

View File

@@ -1,552 +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 {GeneralTypes, UserTypes} from '@mm-redux/action_types';
import {notVisibleUsersActions} from '@mm-redux/actions/helpers';
import {Client4} from '@mm-redux/client';
import {General, Posts, RequestStatus} from '@mm-redux/constants';
import * as Actions from '@actions/websocket';
import {WebsocketEvents} from '@constants';
import TestHelper from 'test/test_helper';
import configureStore from 'test/test_store';
global.WebSocket = MockWebSocket;
const mockConfigRequest = (config = {}) => {
nock(Client4.getBaseRoute()).
get('/config/client?format=old').
reply(200, config);
};
const mockChanelsRequest = (teamId, channels = []) => {
nock(Client4.getUserRoute('me')).
get(`/teams/${teamId}/channels?include_deleted=true`).
reply(200, channels);
};
const mockGetKnownUsersRequest = (userIds = []) => {
nock(Client4.getBaseRoute()).
get('/users/known').
reply(200, userIds);
};
const mockRolesRequest = (rolesToLoad = []) => {
nock(Client4.getRolesRoute()).
post('/names', JSON.stringify(rolesToLoad)).
reply(200, rolesToLoad);
};
const mockTeamMemberRequest = (tm = []) => {
nock(Client4.getUserRoute('me')).
get('/teams/members').
reply(200, tm);
};
describe('Actions.Websocket', () => {
let store;
let mockServer;
beforeAll(async () => {
store = await configureStore();
await TestHelper.initBasic(Client4);
const connUrl = (Client4.getUrl() + '/api/v4/websocket').replace(/^http:/, 'ws:');
mockServer = new Server(connUrl);
return store.dispatch(Actions.init({websocketUrl: Client4.getUrl().replace(/^http:/, 'ws:')}));
});
afterAll(async () => {
Actions.close()();
mockServer.stop();
await TestHelper.tearDown();
});
it('WebSocket Connect', () => {
const ws = store.getState().requests.general.websocket;
assert.ok(ws.status === RequestStatus.SUCCESS);
});
});
describe('Actions.Websocket doReconnect', () => {
const mockStore = configureMockStore([thunk]);
const me = TestHelper.fakeUserWithId();
const team = TestHelper.fakeTeamWithId();
const teamMember = TestHelper.fakeTeamMember(me.id, team.id);
const channel1 = TestHelper.fakeChannelWithId(team.id);
const channel2 = TestHelper.fakeChannelWithId(team.id);
const cMember1 = TestHelper.fakeChannelMember(me.id, channel1.id);
const cMember2 = TestHelper.fakeChannelMember(me.id, channel2.id);
const currentTeamId = team.id;
const currentUserId = me.id;
const currentChannelId = channel1.id;
const initialState = {
entities: {
general: {
config: {},
},
teams: {
currentTeamId,
myMembers: {
[currentTeamId]: teamMember,
},
teams: {
[currentTeamId]: team,
},
},
channels: {
currentChannelId,
channels: {
currentChannelId: channel1,
},
},
users: {
currentUserId,
profiles: {
[me.id]: me,
},
},
preferences: {
myPreferences: {},
},
posts: {
posts: {},
postsInChannel: {},
},
},
websocket: {
connected: false,
lastConnectAt: 0,
lastDisconnectAt: 0,
},
};
beforeAll(async () => {
return TestHelper.initBasic(Client4);
});
beforeEach(() => {
nock(Client4.getBaseRoute()).
get('/users/me').
reply(200, me);
nock(Client4.getUserRoute('me')).
get('/teams').
reply(200, [team]);
nock(Client4.getUserRoute('me')).
get('/teams/unread').
reply(200, [{id: team.id, msg_count: 0, mention_count: 0}]);
nock(Client4.getBaseRoute()).
get('/users/me/preferences').
reply(200, []);
nock(Client4.getUserRoute('me')).
get(`/teams/${team.id}/channels/members`).
reply(200, [cMember1, cMember2]);
nock(Client4.getChannelRoute(channel1.id)).
get(`/posts?page=0&per_page=${Posts.POST_CHUNK_SIZE}`).
reply(200, {
posts: {
post1: {id: 'post1', create_at: 0, message: 'hey'},
},
order: ['post1'],
});
});
afterAll(async () => {
Actions.close()();
await TestHelper.tearDown();
});
it('handle doReconnect', async () => {
const state = {...initialState};
const testStore = await mockStore(state);
const timestamp = 1000;
const expectedActions = [
GeneralTypes.WEBSOCKET_SUCCESS,
];
const expectedMissingActions = [
'BATCH_WS_RECONNECT',
];
mockConfigRequest();
mockTeamMemberRequest([teamMember]);
mockChanelsRequest(team.id, [channel1, channel2]);
let rolesToLoad = Array.from(new Set(me.roles.split(' ').
concat(teamMember.roles.split(' '))));
mockRolesRequest(rolesToLoad);
rolesToLoad = Array.from(new Set(cMember1.roles.split(' ').
concat(cMember2.roles.split(' '))));
mockRolesRequest(rolesToLoad);
await testStore.dispatch(Actions.doReconnect(timestamp));
await TestHelper.wait(300);
const actionTypes = testStore.getActions().map((a) => a.type);
expect(actionTypes).toEqual(expectedActions);
expect(actionTypes).not.toEqual(expect.arrayContaining(expectedMissingActions));
});
it('handle doReconnect after the current channel was archived or the user left it', async () => {
const state = {
...initialState,
entities: {
...initialState.entities,
channels: {
...initialState.entities.channels,
currentChannelId: 'channel-3',
},
},
};
const testStore = await mockStore(state);
const timestamp = 1000;
const expectedActions = [
GeneralTypes.WEBSOCKET_SUCCESS,
'BATCH_WS_RECONNECT',
];
const expectedMissingActions = [
'BATCH_GET_POSTS_SINCE',
];
mockConfigRequest();
mockTeamMemberRequest([teamMember]);
mockChanelsRequest(team.id, [channel1, channel2]);
let rolesToLoad = Array.from(new Set(me.roles.split(' ').
concat(teamMember.roles.split(' '))));
mockRolesRequest(rolesToLoad);
rolesToLoad = Array.from(new Set(cMember1.roles.split(' ').
concat(cMember2.roles.split(' '))));
mockRolesRequest(rolesToLoad);
await testStore.dispatch(Actions.doReconnect(timestamp));
await TestHelper.wait(300);
const actions = testStore.getActions().map((a) => a.type);
expect(actions).toEqual(expect.arrayContaining(expectedActions));
expect(actions).not.toEqual(expect.arrayContaining(expectedMissingActions));
});
it('handle doReconnect after the current channel was archived and setting is on', async () => {
const archived = {
...channel1,
delete_at: 123,
};
const state = {
...initialState,
channels: {
currentChannelId,
channels: {
currentChannelId: archived,
},
},
};
const testStore = await mockStore(state);
const timestamp = 1000;
const expectedActions = [
GeneralTypes.WEBSOCKET_SUCCESS,
];
const expectedMissingActions = [
'BATCH_WS_RECONNECT',
];
mockConfigRequest({ExperimentalViewArchivedChannels: 'true'});
mockTeamMemberRequest([teamMember]);
mockChanelsRequest(team.id, [archived, channel2]);
let rolesToLoad = Array.from(new Set(me.roles.split(' ').
concat(teamMember.roles.split(' '))));
mockRolesRequest(rolesToLoad);
rolesToLoad = Array.from(new Set(cMember1.roles.split(' ').
concat(cMember2.roles.split(' '))));
mockRolesRequest(rolesToLoad);
await testStore.dispatch(Actions.doReconnect(timestamp));
await TestHelper.wait(300);
const actions = testStore.getActions().map((a) => a.type);
expect(actions).toEqual(expect.arrayContaining(expectedActions));
expect(actions).not.toEqual(expect.arrayContaining(expectedMissingActions));
});
it('handle doReconnect after the current channel was archived and setting is off', async () => {
const archived = {
...channel1,
delete_at: 123,
};
const state = {
...initialState,
channels: {
currentChannelId,
channels: {
currentChannelId: archived,
},
},
};
const testStore = await mockStore(state);
const timestamp = 1000;
const expectedActions = [
GeneralTypes.WEBSOCKET_SUCCESS,
'BATCH_WS_RECONNECT',
];
const expectedMissingActions = [
'BATCH_GET_POSTS_SINCE',
];
mockConfigRequest({ExperimentalViewArchivedChannels: 'false'});
mockTeamMemberRequest([teamMember]);
mockChanelsRequest(team.id, [archived, channel2]);
let rolesToLoad = Array.from(new Set(me.roles.split(' ').
concat(teamMember.roles.split(' '))));
mockRolesRequest(rolesToLoad);
rolesToLoad = Array.from(new Set(cMember1.roles.split(' ').
concat(cMember2.roles.split(' '))));
mockRolesRequest(rolesToLoad);
await testStore.dispatch(Actions.doReconnect(timestamp));
await TestHelper.wait(300);
const actions = testStore.getActions().map((a) => a.type);
expect(actions).toEqual(expectedActions);
expect(actions).not.toEqual(expect.arrayContaining(expectedMissingActions));
});
it('handle doReconnect after user left current team', async () => {
const state = {...initialState};
state.entities.teams.myMembers = {};
const testStore = await mockStore(state);
const timestamp = 1000;
const expectedActions = [
GeneralTypes.WEBSOCKET_SUCCESS,
'BATCH_WS_LEAVE_TEAM',
'BATCH_WS_RECONNECT',
];
const expectedMissingActions = [
'BATCH_GET_POSTS_SINCE',
];
mockConfigRequest();
mockTeamMemberRequest([]);
mockChanelsRequest(team.id, [channel1, channel2]);
let rolesToLoad = me.roles.split(' ');
mockRolesRequest(rolesToLoad);
rolesToLoad = Array.from(new Set(cMember1.roles.split(' ').
concat(cMember2.roles.split(' '))));
mockRolesRequest(rolesToLoad);
await testStore.dispatch(Actions.doReconnect(timestamp));
await TestHelper.wait(300);
const actions = testStore.getActions().map((a) => a.type);
expect(actions).toEqual(expectedActions);
expect(actions).not.toEqual(expect.arrayContaining(expectedMissingActions));
});
});
describe('Actions.Websocket notVisibleUsersActions', () => {
configureMockStore([thunk]);
const me = TestHelper.fakeUserWithId();
const user = TestHelper.fakeUserWithId();
const user2 = TestHelper.fakeUserWithId();
const user3 = TestHelper.fakeUserWithId();
const user4 = TestHelper.fakeUserWithId();
const user5 = TestHelper.fakeUserWithId();
it('should do nothing if the known users and the profiles list are the same', async () => {
const profiles = {
[me.id]: me,
[user.id]: user,
[user2.id]: user2,
[user3.id]: user3,
};
Client4.serverVersion = '5.23.0';
const state = {
entities: {
users: {
currentUserId: me.id,
profiles,
},
},
};
mockGetKnownUsersRequest([user.id, user2.id, user3.id]);
const actions = await notVisibleUsersActions(state);
expect(actions.length).toEqual(0);
});
it('should do nothing if there are known users in my memberships but not in the profiles list', async () => {
const profiles = {
[me.id]: me,
[user3.id]: user3,
};
Client4.serverVersion = '5.23.0';
const state = {
entities: {
users: {
currentUserId: me.id,
profiles,
},
},
};
mockGetKnownUsersRequest([user.id, user2.id, user3.id]);
const actions = await notVisibleUsersActions(state);
expect(actions.length).toEqual(0);
});
it('should remove the users if there are unknown users in the profiles list', async () => {
const profiles = {
[me.id]: me,
[user.id]: user,
[user2.id]: user2,
[user3.id]: user3,
[user4.id]: user4,
[user5.id]: user5,
};
Client4.serverVersion = '5.23.0';
const state = {
entities: {
users: {
currentUserId: me.id,
profiles,
},
},
};
mockGetKnownUsersRequest([user.id, user3.id]);
const expectedAction = [
{type: UserTypes.PROFILE_NO_LONGER_VISIBLE, data: {user_id: user2.id}},
{type: UserTypes.PROFILE_NO_LONGER_VISIBLE, data: {user_id: user4.id}},
{type: UserTypes.PROFILE_NO_LONGER_VISIBLE, data: {user_id: user5.id}},
];
const actions = await notVisibleUsersActions(state);
expect(actions.length).toEqual(3);
expect(actions).toEqual(expectedAction);
});
it('should do nothing if the server version is less than 5.23', async () => {
const profiles = {
[me.id]: me,
[user.id]: user,
[user2.id]: user2,
[user3.id]: user3,
[user4.id]: user4,
[user5.id]: user5,
};
Client4.serverVersion = '5.22.0';
const state = {
entities: {
users: {
currentUserId: me.id,
profiles,
},
},
};
mockGetKnownUsersRequest([user.id, user3.id]);
const actions = await notVisibleUsersActions(state);
expect(actions.length).toEqual(0);
});
});
describe('Actions.Websocket handleUserTypingEvent', () => {
const mockStore = configureMockStore([thunk]);
const currentUserId = 'user-id';
const otherUserId = 'other-user-id';
const currentChannelId = 'channel-id';
const otherChannelId = 'other-channel-id';
const initialState = {
entities: {
general: {
config: {},
},
channels: {
currentChannelId,
channels: {
currentChannelId: {
id: currentChannelId,
name: 'channel',
},
},
},
users: {
currentUserId,
profiles: {
[currentUserId]: {},
[otherUserId]: {},
},
statuses: {
[currentUserId]: General.ONLINE,
[otherUserId]: General.OFFLINE,
},
},
preferences: {
myPreferences: {},
},
},
};
it('dispatches actions for current channel if other user is typing', async () => {
const state = {...initialState};
const testStore = await mockStore(state);
const msg = {broadcast: {channel_id: currentChannelId}, data: {parent_id: 'parent-id', user_id: otherUserId}};
nock(Client4.getUsersRoute()).
post('/status/ids', JSON.stringify([otherUserId])).
reply(200, ['away']);
const expectedActionsTypes = [
WebsocketEvents.TYPING,
UserTypes.RECEIVED_STATUSES,
];
await testStore.dispatch(Actions.handleUserTypingEvent(msg));
await TestHelper.wait(300);
const actionTypes = testStore.getActions().map((action) => action.type);
expect(actionTypes).toEqual(expectedActionsTypes);
});
it('does not dispatch actions for non current channel', async () => {
const state = {...initialState};
const testStore = await mockStore(state);
const msg = {broadcast: {channel_id: otherChannelId}, data: {parent_id: 'parent-id', user_id: otherUserId}};
const expectedActionsTypes = [];
await testStore.dispatch(Actions.handleUserTypingEvent(msg));
const actionTypes = testStore.getActions().map((action) => action.type);
expect(actionTypes).toEqual(expectedActionsTypes);
});
});

View File

@@ -1,43 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FormattedTime should fallback to default short format for unsupported locale of react-intl 1`] = `
<Text>
8:47 AM
</Text>
`;
exports[`FormattedTime should fallback to default short format for unsupported locale of react-intl 2`] = `
<Text>
8:47
</Text>
`;
exports[`FormattedTime should render correctly 1`] = `
<Text>
7:02 PM
</Text>
`;
exports[`FormattedTime should render correctly 2`] = `
<Text>
19:02
</Text>
`;
exports[`FormattedTime should support localization 1`] = `
<Text>
7:02 PM
</Text>
`;
exports[`FormattedTime should support localization 2`] = `
<Text>
오후 7:02
</Text>
`;
exports[`FormattedTime should support localization 3`] = `
<Text>
19:02
</Text>
<View
pointerEvents="box-none"
style={
Object {
"flex": 1,
}
}
>
<View
collapsable={true}
pointerEvents="box-none"
style={
Object {
"flex": 1,
}
}
>
<Text>
7:02 PM
</Text>
</View>
</View>
`;

View File

@@ -2,6 +2,7 @@
exports[`profile_picture_button should match snapshot 1`] = `
<AttachmentButton
blurTextBox={[MockFunction]}
browseFileTypes="public.item"
canBrowseFiles={true}
canBrowsePhotoLibrary={true}

View File

@@ -1,133 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AnnouncementBanner should match snapshot 1`] = `
<Unknown
bannerColor="#ddd"
bannerDismissed={false}
bannerEnabled={true}
bannerText="Banner Text"
bannerTextColor="#fff"
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
<ForwardRef(AnimatedComponentWrapper)
style={
Array [
Object {
"overflow": "hidden",
"paddingHorizontal": 10,
"position": "absolute",
"top": 0,
"width": "100%",
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": "span",
"timeZone": null,
}
Object {
"backgroundColor": "#ddd",
"height": 0,
},
]
}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
>
<Component
onPress={[Function]}
style={
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
},
null,
]
}
}
/>
>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"flex": 1,
"fontSize": 14,
"marginRight": 5,
},
Object {
"color": "#fff",
},
]
}
>
<RemoveMarkdown
value="Banner Text"
/>
</Text>
<Icon
allowFontScaling={false}
color="#fff"
name="info"
size={16}
/>
</Component>
</ForwardRef(AnimatedComponentWrapper)>
`;
exports[`AnnouncementBanner should match snapshot 2`] = `
<Unknown
bannerColor="#ddd"
bannerDismissed={false}
bannerEnabled={false}
bannerText="Banner Text"
bannerTextColor="#fff"
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": "span",
"timeZone": null,
}
}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
`;
exports[`AnnouncementBanner should match snapshot 2`] = `null`;

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useRef, useState} from 'react';
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Animated,
@@ -9,25 +9,53 @@ import {
Text,
TouchableOpacity,
} from 'react-native';
import {injectIntl} from 'react-intl';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {intlShape} from 'react-intl';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import {goToScreen} from '@actions/navigation';
import CompassIcon from '@components/compass_icon';
import RemoveMarkdown from '@components/remove_markdown';
import {ViewTypes} from '@constants';
import EventEmitter from '@mm-redux/utils/event_emitter';
import RemoveMarkdown from 'app/components/remove_markdown';
import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing';
import {goToScreen} from 'app/actions/navigation';
const {View: AnimatedView} = Animated;
const AnnouncementBanner = injectIntl((props) => {
const {bannerColor, bannerDismissed, bannerEnabled, bannerText, bannerTextColor, intl} = props;
const insets = useSafeAreaInsets();
const translateY = useRef(new Animated.Value(0)).current;
const [visible, setVisible] = useState(false);
const [navHeight, setNavHeight] = useState(0);
export default class AnnouncementBanner extends PureComponent {
static propTypes = {
bannerColor: PropTypes.string,
bannerDismissed: PropTypes.bool,
bannerEnabled: PropTypes.bool,
bannerText: PropTypes.string,
bannerTextColor: PropTypes.string,
theme: PropTypes.object.isRequired,
isLandscape: PropTypes.bool.isRequired,
};
static contextTypes = {
intl: intlShape,
};
state = {
bannerHeight: new Animated.Value(0),
};
componentDidMount() {
const {bannerDismissed, bannerEnabled, bannerText} = this.props;
const showBanner = bannerEnabled && !bannerDismissed && Boolean(bannerText);
this.toggleBanner(showBanner);
}
componentDidUpdate(prevProps) {
if (this.props.bannerText !== prevProps.bannerText ||
this.props.bannerEnabled !== prevProps.bannerEnabled ||
this.props.bannerDismissed !== prevProps.bannerDismissed
) {
const showBanner = this.props.bannerEnabled && !this.props.bannerDismissed && Boolean(this.props.bannerText);
this.toggleBanner(showBanner);
}
}
handlePress = () => {
const {intl} = this.context;
const handlePress = () => {
const screen = 'ExpandedAnnouncementBanner';
const title = intl.formatMessage({
id: 'mobile.announcement_banner.title',
@@ -37,88 +65,70 @@ const AnnouncementBanner = injectIntl((props) => {
goToScreen(screen, title);
};
useEffect(() => {
const handleNavbarHeight = (height) => {
setNavHeight(height);
toggleBanner = (show = true) => {
const value = show ? 38 : 0;
Animated.timing(this.state.bannerHeight, {
toValue: value,
duration: 350,
useNativeDriver: false,
}).start();
};
render() {
if (!this.props.bannerEnabled) {
return null;
}
const {bannerHeight} = this.state;
const {
bannerColor,
bannerText,
bannerTextColor,
isLandscape,
} = this.props;
const bannerStyle = {
backgroundColor: bannerColor,
height: bannerHeight,
};
EventEmitter.on(ViewTypes.CHANNEL_NAV_BAR_CHANGED, handleNavbarHeight);
const bannerTextStyle = {
color: bannerTextColor,
};
return () => EventEmitter.off(ViewTypes.CHANNEL_NAV_BAR_CHANGED, handleNavbarHeight);
}, [insets]);
useEffect(() => {
const showBanner = bannerEnabled && !bannerDismissed && Boolean(bannerText);
setVisible(showBanner);
EventEmitter.emit(ViewTypes.INDICATOR_BAR_VISIBLE, showBanner);
}, [bannerDismissed, bannerEnabled, bannerText]);
useEffect(() => {
Animated.timing(translateY, {
toValue: visible ? navHeight : insets.top,
duration: 50,
useNativeDriver: true,
}).start();
}, [visible, navHeight]);
if (!visible) {
return null;
}
const bannerStyle = {
backgroundColor: bannerColor,
height: ViewTypes.INDICATOR_BAR_HEIGHT,
transform: [{translateY}],
};
const bannerTextStyle = {
color: bannerTextColor,
};
return (
<AnimatedView
style={[style.bannerContainer, bannerStyle]}
>
<TouchableOpacity
onPress={handlePress}
style={[style.wrapper, {marginLeft: insets.left, marginRight: insets.right}]}
return (
<AnimatedView
style={[style.bannerContainer, bannerStyle]}
>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={[style.bannerText, bannerTextStyle]}
<TouchableOpacity
onPress={this.handlePress}
style={[style.wrapper, padding(isLandscape)]}
>
<RemoveMarkdown value={bannerText}/>
</Text>
<CompassIcon
color={bannerTextColor}
name='information-outline'
size={16}
/>
</TouchableOpacity>
</AnimatedView>
);
});
AnnouncementBanner.propTypes = {
bannerColor: PropTypes.string,
bannerDismissed: PropTypes.bool,
bannerEnabled: PropTypes.bool,
bannerText: PropTypes.string,
bannerTextColor: PropTypes.string,
};
export default AnnouncementBanner;
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={[style.bannerText, bannerTextStyle]}
>
<RemoveMarkdown value={bannerText}/>
</Text>
<MaterialIcons
color={bannerTextColor}
name='info'
size={16}
/>
</TouchableOpacity>
</AnimatedView>
);
}
}
const style = StyleSheet.create({
bannerContainer: {
elevation: 2,
paddingHorizontal: 10,
position: 'absolute',
top: 0,
overflow: 'hidden',
width: '100%',
zIndex: 2,
},
wrapper: {
alignItems: 'center',

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {shallowWithIntl} from 'test/intl-test-helper';
import {shallow} from 'enzyme';
import Preferences from '@mm-redux/constants/preferences';
@@ -18,10 +18,11 @@ describe('AnnouncementBanner', () => {
bannerText: 'Banner Text',
bannerTextColor: '#fff',
theme: Preferences.THEMES.default,
isLandscape: false,
};
test('should match snapshot', () => {
const wrapper = shallowWithIntl(
const wrapper = shallow(
<AnnouncementBanner {...baseProps}/>,
);

View File

@@ -4,6 +4,7 @@
import {connect} from 'react-redux';
import {getConfig, getLicense} from '@mm-redux/selectors/entities/general';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {isLandscape} from 'app/selectors/device';
@@ -20,6 +21,7 @@ function mapStateToProps(state) {
bannerEnabled: config.EnableBanner === 'true' && license.IsLicensed === 'true',
bannerText: config.BannerText,
bannerTextColor: config.BannerTextColor || '#000',
theme: getTheme(state),
isLandscape: isLandscape(state),
};
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import Svg, {
G,
Path,
} from 'react-native-svg';
export default class AppIcon extends PureComponent {
static propTypes = {
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
color: PropTypes.string.isRequired,
};
render() {
return (
<Svg
height={this.props.height}
width={this.props.width}
viewBox='0 0 500 500'
>
<G id='XMLID_1_'>
<G id='XMLID_3_'>
<Path
id='XMLID_4_'
class='st0'
d='M396.9,47.7l2.6,53.1c43,47.5,60,114.8,38.6,178.1c-32,94.4-137.4,144.1-235.4,110.9 S51.1,253.1,83,158.7C104.5,95.2,159.2,52,222.5,40.5l34.2-40.4C150-2.8,49.3,63.4,13.3,169.9C-31,300.6,39.1,442.5,169.9,486.7 s272.6-25.8,316.9-156.6C522.7,223.9,483.1,110.3,396.9,47.7z'
fill={this.props.color}
/>
</G>
<Path
id='XMLID_2_'
class='st0'
d='M335.6,204.3l-1.8-74.2l-1.5-42.7l-1-37c0,0,0.2-17.8-0.4-22c-0.1-0.9-0.4-1.6-0.7-2.2 c0-0.1-0.1-0.2-0.1-0.3c0-0.1-0.1-0.2-0.1-0.2c-0.7-1.2-1.8-2.1-3.1-2.6c-1.4-0.5-2.9-0.4-4.2,0.2c0,0-0.1,0-0.1,0 c-0.2,0.1-0.3,0.1-0.4,0.2c-0.6,0.3-1.2,0.7-1.8,1.3c-3,3-13.7,17.2-13.7,17.2l-23.2,28.8l-27.1,33l-46.5,57.8 c0,0-21.3,26.6-16.6,59.4s29.1,48.7,48,55.1c18.9,6.4,48,8.5,71.6-14.7C336.4,238.4,335.6,204.3,335.6,204.3z'
fill={this.props.color}
/>
</G>
</Svg>
);
}
}

View File

@@ -1,38 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import FormattedText from '@components/formatted_text';
const style = StyleSheet.create({
info: {
alignItems: 'center',
justifyContent: 'flex-end',
},
version: {
fontSize: 12,
},
});
const AppVersion = () => {
return (
<View pointerEvents='none'>
<View style={style.info}>
<FormattedText
id='mobile.about.appVersion'
defaultMessage='App Version: {version} (Build {number})'
style={style.version}
values={{
version: DeviceInfo.getVersion(),
number: DeviceInfo.getBuildNumber(),
}}
/>
</View>
</View>
);
};
export default AppVersion;

View File

@@ -1,20 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AtMention should match snapshot, no highlight 1`] = `
<Text
style={Object {}}
>
<Text
style={
Array [
Object {
"backgroundColor": "yellow",
},
]
}
>
@John.Smith
</Text>
<Text>
@John.Smith
</Text>
`;
@@ -22,22 +10,13 @@ exports[`AtMention should match snapshot, with highlight 1`] = `
<Text
onLongPress={[Function]}
onPress={[Function]}
style={Object {}}
>
<Text
style={
Array [
Object {
"color": "#ff0000",
},
Object {
"backgroundColor": "yellow",
},
]
}
style={null}
>
@John.Smith
</Text>
</Text>
`;
@@ -45,18 +24,16 @@ exports[`AtMention should match snapshot, without highlight 1`] = `
<Text
onLongPress={[Function]}
onPress={[Function]}
style={Object {}}
>
<Text
style={
Array [
Object {
"color": "#ff0000",
},
]
Object {
"color": "#ff0000",
}
}
>
@Victor.Welch
</Text>
</Text>
`;

View File

@@ -3,16 +3,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import {StyleSheet, Text} from 'react-native';
import Clipboard from '@react-native-community/clipboard';
import {Clipboard, Text} from 'react-native';
import {intlShape} from 'react-intl';
import {displayUsername} from '@mm-redux/utils/user_utils';
import {showModal} from '@actions/navigation';
import CompassIcon from '@components/compass_icon';
import CustomPropTypes from '@constants/custom_prop_types';
import BottomSheet from '@utils/bottom_sheet';
import CustomPropTypes from 'app/constants/custom_prop_types';
import mattermostManaged from 'app/mattermost_managed';
import BottomSheet from 'app/utils/bottom_sheet';
import {goToScreen} from 'app/actions/navigation';
export default class AtMention extends React.PureComponent {
static propTypes = {
@@ -25,7 +24,6 @@ export default class AtMention extends React.PureComponent {
teammateNameDisplay: PropTypes.string,
theme: PropTypes.object.isRequired,
usersByUsername: PropTypes.object.isRequired,
groupsByName: PropTypes.object,
};
static contextTypes = {
@@ -47,29 +45,15 @@ export default class AtMention extends React.PureComponent {
}
}
goToUserProfile = async () => {
goToUserProfile = () => {
const {intl} = this.context;
const {theme} = this.props;
const screen = 'UserProfile';
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const passProps = {
userId: this.state.user.id,
};
if (!this.closeButton) {
this.closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
}
const options = {
topBar: {
leftButtons: [{
id: 'close-settings',
icon: this.closeButton,
}],
},
};
showModal(screen, title, passProps, options);
goToScreen(screen, title, passProps);
};
getUserDetailsFromMentionName() {
@@ -94,12 +78,6 @@ export default class AtMention extends React.PureComponent {
};
}
getGroupFromMentionName() {
const {groupsByName, mentionName} = this.props;
const mentionNameTrimmed = mentionName.toLowerCase().replace(/[._-]*$/, '');
return groupsByName?.[mentionNameTrimmed] || {};
}
handleLongPress = async () => {
const {formatMessage} = this.context.intl;
@@ -141,85 +119,24 @@ export default class AtMention extends React.PureComponent {
render() {
const {isSearchResult, mentionName, mentionStyle, onPostPress, teammateNameDisplay, textStyle, mentionKeys} = this.props;
const {user} = this.state;
const mentionTextStyle = [];
let backgroundColor;
let canPress = false;
let highlighted;
let isMention = false;
let mention;
let onLongPress;
let onPress;
let suffix;
let suffixElement;
let styleText;
if (textStyle) {
const {backgroundColor: bg, ...otherStyles} = StyleSheet.flatten(textStyle);
backgroundColor = bg;
styleText = otherStyles;
if (!user.username) {
return <Text style={textStyle}>{'@' + mentionName}</Text>;
}
if (user?.username) {
suffix = this.props.mentionName.substring(user.username.length);
highlighted = mentionKeys.some((item) => item.key.includes(user.username));
mention = displayUsername(user, teammateNameDisplay);
isMention = true;
canPress = true;
} else {
const group = this.getGroupFromMentionName();
if (group.allow_reference) {
highlighted = mentionKeys.some((item) => item.key === `@${group.name}`);
isMention = true;
mention = group.name;
suffix = this.props.mentionName.substring(group.name.length);
} else {
const pattern = new RegExp(/\b(all|channel|here)(?:\.\B|_\b|\b)/, 'i');
const mentionMatch = pattern.exec(mentionName);
highlighted = true;
if (mentionMatch) {
mention = mentionMatch.length > 1 ? mentionMatch[1] : mentionMatch[0];
suffix = mentionName.replace(mention, '');
isMention = true;
} else {
mention = mentionName;
}
}
}
if (canPress) {
onLongPress = this.handleLongPress;
onPress = isSearchResult ? onPostPress : this.goToUserProfile;
}
if (suffix) {
const suffixStyle = {...styleText, color: this.props.theme.centerChannelColor};
suffixElement = (
<Text style={suffixStyle}>
{suffix}
</Text>
);
}
if (isMention) {
mentionTextStyle.push(mentionStyle);
}
if (highlighted) {
mentionTextStyle.push({backgroundColor});
}
const suffix = this.props.mentionName.substring(user.username.length);
const highlighted = mentionKeys.some((item) => item.key === user.username);
return (
<Text
style={styleText}
onPress={onPress}
onLongPress={onLongPress}
style={textStyle}
onPress={isSearchResult ? onPostPress : this.goToUserProfile}
onLongPress={this.handleLongPress}
>
<Text style={mentionTextStyle}>
{'@' + mention}
<Text style={highlighted ? null : mentionStyle}>
{'@' + displayUsername(user, teammateNameDisplay)}
</Text>
{suffixElement}
{suffix}
</Text>
);
}

View File

@@ -12,7 +12,6 @@ describe('AtMention', () => {
teammateNameDisplay: '',
mentionName: 'John.Smith',
mentionStyle: {color: '#ff0000'},
textStyle: {backgroundColor: 'yellow'},
theme: {},
};

View File

@@ -3,23 +3,18 @@
import {connect} from 'react-redux';
import {getUsersByUsername} from '@mm-redux/selectors/entities/users';
import {getAllUserMentionKeys} from '@mm-redux/selectors/entities/search';
import {getUsersByUsername, getCurrentUserMentionKeys} from '@mm-redux/selectors/entities/users';
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
import {getAllGroupsForReferenceByName} from '@mm-redux/selectors/entities/groups';
import AtMention from './at_mention';
function mapStateToProps(state, ownProps) {
function mapStateToProps(state) {
return {
theme: getTheme(state),
usersByUsername: getUsersByUsername(state),
mentionKeys: ownProps.mentionKeys || getAllUserMentionKeys(state),
mentionKeys: getCurrentUserMentionKeys(state),
teammateNameDisplay: getTeammateNameDisplaySetting(state),
groupsByName: getAllGroupsForReferenceByName(state),
};
}

View File

@@ -13,9 +13,10 @@ exports[`AttachmentButton should match snapshot 1`] = `
}
type="opacity"
>
<CompassIcon
<Icon
allowFontScaling={false}
color="rgba(61,60,64,0.9)"
name="plus"
name="md-add"
size={30}
style={
Object {

View File

@@ -14,24 +14,24 @@ import RNFetchBlob from 'rn-fetch-blob';
import DeviceInfo from 'react-native-device-info';
import AndroidOpenSettings from 'react-native-android-open-settings';
import Icon from 'react-native-vector-icons/Ionicons';
import DocumentPicker from 'react-native-document-picker';
import ImagePicker from 'react-native-image-picker';
import Permissions from 'react-native-permissions';
import {showModalOverCurrentContext} from '@actions/navigation';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {NavigationTypes} from '@constants';
import emmProvider from '@init/emm_provider';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {lookupMimeType} from '@mm-redux/utils/file_utils';
import {t} from '@utils/i18n';
import {changeOpacity} from '@utils/theme';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import emmProvider from 'app/init/emm_provider';
import {changeOpacity} from 'app/utils/theme';
import {t} from 'app/utils/i18n';
import {showModalOverCurrentContext} from 'app/actions/navigation';
const ShareExtension = NativeModules.MattermostShare;
export default class AttachmentButton extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
browseFileTypes: PropTypes.string,
validMimeTypes: PropTypes.array,
canBrowseFiles: PropTypes.bool,
@@ -414,7 +414,7 @@ export default class AttachmentButton extends PureComponent {
return;
}
EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT);
this.props.blurTextBox();
const items = [];
if (canTakePhoto) {
@@ -424,7 +424,7 @@ export default class AttachmentButton extends PureComponent {
id: t('mobile.file_upload.camera_photo'),
defaultMessage: 'Take Photo',
},
icon: 'camera-outline',
icon: 'camera',
});
}
@@ -435,7 +435,7 @@ export default class AttachmentButton extends PureComponent {
id: t('mobile.file_upload.camera_video'),
defaultMessage: 'Take Video',
},
icon: 'video-outline',
icon: 'video-camera',
});
}
@@ -446,7 +446,7 @@ export default class AttachmentButton extends PureComponent {
id: t('mobile.file_upload.library'),
defaultMessage: 'Photo Library',
},
icon: 'file-image-outline',
icon: 'photo',
});
}
@@ -457,7 +457,7 @@ export default class AttachmentButton extends PureComponent {
id: t('mobile.file_upload.video'),
defaultMessage: 'Video Library',
},
icon: 'file-video-outline',
icon: 'file-video-o',
});
}
@@ -468,7 +468,7 @@ export default class AttachmentButton extends PureComponent {
id: t('mobile.file_upload.browse'),
defaultMessage: 'Browse Files',
},
icon: 'file-outline',
icon: 'file',
});
}
@@ -503,11 +503,11 @@ export default class AttachmentButton extends PureComponent {
style={style.buttonContainer}
type={'opacity'}
>
<CompassIcon
<Icon
size={30}
style={style.attachIcon}
color={changeOpacity(theme.centerChannelColor, 0.9)}
name='plus'
name='md-add'
/>
</TouchableWithFeedback>
);

View File

@@ -23,6 +23,7 @@ describe('AttachmentButton', () => {
const formatMessage = jest.fn();
const baseProps = {
theme: Preferences.THEMES.default,
blurTextBox: jest.fn(),
maxFileSize: 10,
uploadFiles: jest.fn(),
};

View File

@@ -9,9 +9,9 @@ import {RequestStatus} from '@mm-redux/constants';
import {AT_MENTION_REGEX, AT_MENTION_SEARCH_REGEX} from 'app/constants/autocomplete';
import AtMentionItem from 'app/components/autocomplete/at_mention_item';
import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divider';
import AutocompleteSectionHeader from 'app/components/autocomplete/autocomplete_section_header';
import SpecialMentionItem from 'app/components/autocomplete/special_mention_item';
import GroupMentionItem from 'app/components/autocomplete/at_mention_group/at_mention_group';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import {t} from 'app/utils/i18n';
@@ -35,9 +35,9 @@ export default class AtMention extends PureComponent {
teamMembers: PropTypes.array,
theme: PropTypes.object.isRequired,
value: PropTypes.string,
isLandscape: PropTypes.bool.isRequired,
nestedScrollEnabled: PropTypes.bool,
useChannelMentions: PropTypes.bool.isRequired,
groups: PropTypes.array,
};
static defaultProps = {
@@ -56,40 +56,71 @@ export default class AtMention extends PureComponent {
}
componentWillReceiveProps(nextProps) {
const {groups, inChannel, outChannel, teamMembers, isSearch, matchTerm, requestStatus} = nextProps;
// Not invoked, render nothing.
if (matchTerm === null) {
const {inChannel, outChannel, teamMembers, isSearch, matchTerm, requestStatus} = nextProps;
if ((matchTerm !== this.props.matchTerm && matchTerm === null) || this.state.mentionComplete) {
// if the term changes but is null or the mention has been completed we render this component as null
this.setState({
mentionComplete: false,
sections: [],
});
this.props.onResultCountChange(0);
return;
} else if (matchTerm === null) {
// if the terms did not change but is null then we don't need to do anything
return;
}
if (matchTerm !== this.props.matchTerm) {
const sections = this.buildSections(nextProps);
this.setState({
sections,
});
this.props.onResultCountChange(sections.reduce((total, section) => total + section.data.length, 0));
// Update user autocomplete list with results of server request
// if the term changed and we haven't made the request do that first
const {currentTeamId, currentChannelId} = this.props;
const channelId = isSearch ? '' : currentChannelId;
this.props.actions.autocompleteUsers(matchTerm, currentTeamId, channelId);
return;
}
// Server request is complete
if (
groups !== this.props.groups ||
(
requestStatus !== RequestStatus.STARTED &&
(inChannel !== this.props.inChannel || outChannel !== this.props.outChannel || teamMembers !== this.props.teamMembers)
)
) {
const sections = this.buildSections(nextProps);
if (requestStatus !== RequestStatus.STARTED &&
(inChannel !== this.props.inChannel || outChannel !== this.props.outChannel || teamMembers !== this.props.teamMembers)) {
// if the request is complete and the term is not null we show the autocomplete
const sections = [];
if (isSearch) {
sections.push({
id: t('mobile.suggestion.members'),
defaultMessage: 'Members',
data: teamMembers,
key: 'teamMembers',
});
} else {
if (inChannel.length) {
sections.push({
id: t('suggestion.mention.members'),
defaultMessage: 'Channel Members',
data: inChannel,
key: 'inChannel',
});
}
if (this.props.useChannelMentions && this.checkSpecialMentions(matchTerm)) {
sections.push({
id: t('suggestion.mention.special'),
defaultMessage: 'Special Mentions',
data: this.getSpecialMentions(),
key: 'special',
renderItem: this.renderSpecialMentions,
});
}
if (outChannel.length) {
sections.push({
id: t('suggestion.mention.nonmembers'),
defaultMessage: 'Not in Channel',
data: outChannel,
key: 'outChannel',
});
}
}
this.setState({
sections,
});
@@ -98,66 +129,6 @@ export default class AtMention extends PureComponent {
}
}
componentDidUpdate(prevProps, prevState) {
if (prevState.sections.length !== this.state.sections.length && this.state.sections.length === 0) {
this.props.onResultCountChange(0);
}
}
buildSections = (props) => {
const {isSearch, inChannel, outChannel, teamMembers, matchTerm, groups} = props;
const sections = [];
if (isSearch) {
sections.push({
id: t('mobile.suggestion.members'),
defaultMessage: 'Members',
data: teamMembers,
key: 'teamMembers',
});
} else {
if (inChannel.length) {
sections.push({
id: t('suggestion.mention.members'),
defaultMessage: 'Channel Members',
data: inChannel,
key: 'inChannel',
});
}
if (groups.length) {
sections.push({
id: t('suggestion.mention.groups'),
defaultMessage: 'Group Mentions',
data: groups,
key: 'groups',
renderItem: this.renderGroupMentions,
});
}
if (this.props.useChannelMentions && this.checkSpecialMentions(matchTerm)) {
sections.push({
id: t('suggestion.mention.special'),
defaultMessage: 'Special Mentions',
data: this.getSpecialMentions(),
key: 'special',
renderItem: this.renderSpecialMentions,
});
}
if (outChannel.length) {
sections.push({
id: t('suggestion.mention.nonmembers'),
defaultMessage: 'Not in Channel',
data: outChannel,
key: 'outChannel',
});
}
}
return sections;
};
keyExtractor = (item) => {
return item.id || item;
};
@@ -195,24 +166,22 @@ export default class AtMention extends PureComponent {
} else {
completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `);
}
if (value.length > cursorPosition) {
completedDraft += value.substring(cursorPosition);
}
onChangeText(completedDraft);
this.setState({
sections: [],
});
this.setState({mentionComplete: true});
};
renderSectionHeader = ({section}) => {
const isFirstSection = section.id === this.state.sections[0].id;
return (
<AutocompleteSectionHeader
id={section.id}
defaultMessage={section.defaultMessage}
theme={this.props.theme}
isFirstSection={isFirstSection}
isLandscape={this.props.isLandscape}
/>
);
};
@@ -220,7 +189,6 @@ export default class AtMention extends PureComponent {
renderItem = ({item}) => {
return (
<AtMentionItem
testID={`autocomplete.at_mention.item.${item}`}
onPress={this.completeMention}
userId={item}
/>
@@ -240,21 +208,11 @@ export default class AtMention extends PureComponent {
);
};
renderGroupMentions = ({item}) => {
return (
<GroupMentionItem
key={`autocomplete-group-${item.name}`}
completeHandle={item.name}
onPress={this.completeMention}
theme={this.props.theme}
/>
);
};
render() {
const {maxListHeight, theme, nestedScrollEnabled} = this.props;
const {sections} = this.state;
if (sections.length === 0) {
const {mentionComplete, sections} = this.state;
if (sections.length === 0 || mentionComplete) {
// If we are not in an active state or the mention has been completed return null so nothing is rendered
// other components are not blocked.
return null;
@@ -264,13 +222,13 @@ export default class AtMention extends PureComponent {
return (
<SectionList
testID='at_mention_suggestion.list'
keyboardShouldPersistTaps='always'
keyExtractor={this.keyExtractor}
style={[style.listView, {maxHeight: maxListHeight}]}
sections={sections}
renderItem={this.renderItem}
renderSectionHeader={this.renderSectionHeader}
ItemSeparatorComponent={AutocompleteDivider}
initialNumToRender={10}
nestedScrollEnabled={nestedScrollEnabled}
/>
@@ -282,7 +240,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
listView: {
backgroundColor: theme.centerChannelBg,
borderRadius: 4,
},
};
});

View File

@@ -6,10 +6,9 @@ import {connect} from 'react-redux';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {autocompleteUsers} from '@mm-redux/actions/users';
import {getLicense} from '@mm-redux/selectors/entities/general';
import {getCurrentChannelId, getDefaultChannel} from '@mm-redux/selectors/entities/channels';
import {getAssociatedGroupsForReference, searchAssociatedGroupsForReferenceLocal} from '@mm-redux/selectors/entities/groups';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {isLandscape} from 'app/selectors/device';
import {
filterMembersInChannel,
@@ -27,16 +26,13 @@ import AtMention from './at_mention';
function mapStateToProps(state, ownProps) {
const {cursorPosition, isSearch} = ownProps;
const currentChannelId = getCurrentChannelId(state);
const currentTeamId = getCurrentTeamId(state);
const license = getLicense(state);
const hasLicense = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true';
let useChannelMentions = true;
if (isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) {
useChannelMentions = haveIChannelPermission(
state,
{
channel: currentChannelId,
team: currentTeamId,
permission: Permissions.USE_CHANNEL_MENTIONS,
default: true,
},
@@ -49,7 +45,6 @@ function mapStateToProps(state, ownProps) {
let teamMembers;
let inChannel;
let outChannel;
let groups = [];
if (isSearch) {
teamMembers = filterMembersInCurrentTeam(state, matchTerm);
} else {
@@ -57,17 +52,9 @@ function mapStateToProps(state, ownProps) {
outChannel = filterMembersNotInChannel(state, matchTerm);
}
if (haveIChannelPermission(state, {channel: currentChannelId, team: currentTeamId, permission: Permissions.USE_GROUP_MENTIONS, default: true}) && hasLicense && isMinimumServerVersion(state.entities.general.serverVersion, 5, 24)) {
if (matchTerm) {
groups = searchAssociatedGroupsForReferenceLocal(state, matchTerm, currentTeamId, currentChannelId);
} else {
groups = getAssociatedGroupsForReference(state, currentTeamId, currentChannelId);
}
}
return {
currentChannelId,
currentTeamId,
currentTeamId: getCurrentTeamId(state),
defaultChannel: getDefaultChannel(state),
matchTerm,
teamMembers,
@@ -75,8 +62,8 @@ function mapStateToProps(state, ownProps) {
outChannel,
requestStatus: state.requests.users.autocompleteUsers.status,
theme: getTheme(state),
isLandscape: isLandscape(state),
useChannelMentions,
groups,
};
}

View File

@@ -1,86 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {
Text,
View,
} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
row: {
paddingVertical: 8,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
},
rowPicture: {
marginHorizontal: 8,
width: 20,
alignItems: 'center',
justifyContent: 'center',
},
rowIcon: {
color: changeOpacity(theme.centerChannelColor, 0.7),
fontSize: 14,
},
rowUsername: {
fontSize: 13,
color: theme.centerChannelColor,
},
rowFullname: {
color: theme.centerChannelColor,
flex: 1,
opacity: 0.6,
},
textWrapper: {
flex: 1,
flexWrap: 'wrap',
paddingRight: 8,
},
};
});
const GroupMentionItem = (props) => {
const insets = useSafeAreaInsets();
const {onPress, completeHandle, theme} = props;
const completeMention = () => {
onPress(completeHandle);
};
const style = getStyleFromTheme(theme);
return (
<TouchableWithFeedback
onPress={completeMention}
style={[style.row, {marginLeft: insets.left, marginRight: insets.right}]}
type={'opacity'}
>
<View style={style.rowPicture}>
<CompassIcon
name='account-group-outline'
style={style.rowIcon}
/>
</View>
<Text style={style.rowUsername}>{`@${completeHandle}`}</Text>
<Text style={style.rowUsername}>{' - '}</Text>
<Text style={style.rowFullname}>{`${completeHandle}`}</Text>
</TouchableWithFeedback>
);
};
GroupMentionItem.propTypes = {
completeHandle: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
};
export default GroupMentionItem;

View File

@@ -1,106 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Text, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {
Text,
View,
} from 'react-native';
import FormattedText from '@components/formatted_text';
import ProfilePicture from '@components/profile_picture';
import {BotTag, GuestTag} from '@components/tag';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import ProfilePicture from 'app/components/profile_picture';
import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing';
import {BotTag, GuestTag} from 'app/components/tag';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import FormattedText from 'app/components/formatted_text';
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
row: {
height: 40,
paddingVertical: 8,
paddingTop: 4,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
},
rowPicture: {
marginRight: 10,
marginLeft: 2,
width: 24,
alignItems: 'center',
justifyContent: 'center',
},
rowFullname: {
fontSize: 15,
color: theme.centerChannelColor,
paddingLeft: 4,
},
rowUsername: {
color: theme.centerChannelColor,
fontSize: 15,
opacity: 0.56,
flex: 1,
},
export default class AtMentionItem extends PureComponent {
static propTypes = {
firstName: PropTypes.string,
lastName: PropTypes.string,
nickname: PropTypes.string,
onPress: PropTypes.func.isRequired,
userId: PropTypes.string.isRequired,
username: PropTypes.string,
isGuest: PropTypes.bool,
isBot: PropTypes.bool,
theme: PropTypes.object.isRequired,
isLandscape: PropTypes.bool.isRequired,
isCurrentUser: PropTypes.bool.isRequired,
};
});
const AtMentionItem = (props) => {
const insets = useSafeAreaInsets();
const {
firstName,
isBot,
isCurrentUser,
isGuest,
lastName,
nickname,
onPress,
showFullName,
testID,
theme,
userId,
username,
} = props;
static defaultProps = {
firstName: '',
lastName: '',
};
const completeMention = () => {
completeMention = () => {
const {onPress, username} = this.props;
onPress(username);
};
const renderNameBlock = () => {
let name = '';
render() {
const {
firstName,
lastName,
nickname,
userId,
username,
theme,
isBot,
isLandscape,
isGuest,
isCurrentUser,
} = this.props;
const style = getStyleFromTheme(theme);
const hasFullName = firstName.length > 0 && lastName.length > 0;
const hasNickname = nickname.length > 0;
if (showFullName === 'true') {
name += `${firstName} ${lastName} `;
}
if (hasNickname) {
name += `(${nickname})`;
}
return name.trim();
};
const style = getStyleFromTheme(theme);
const name = renderNameBlock();
return (
<TouchableWithFeedback
testID={testID}
key={userId}
onPress={completeMention}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
style={{marginLeft: insets.left, marginRight: insets.right}}
type={'native'}
>
<View style={style.row}>
return (
<TouchableWithFeedback
key={userId}
onPress={this.completeMention}
style={[style.row, padding(isLandscape)]}
type={'opacity'}
>
<View style={style.rowPicture}>
<ProfilePicture
userId={userId}
theme={theme}
size={24}
size={20}
status={null}
showStatus={false}
/>
</View>
<Text style={style.rowUsername}>{`@${username}`}</Text>
<BotTag
show={isBot}
theme={theme}
@@ -109,48 +82,46 @@ const AtMentionItem = (props) => {
show={isGuest}
theme={theme}
/>
{Boolean(name.length) &&
{hasFullName && <Text style={style.rowUsername}>{' - '}</Text>}
<Text
style={style.rowFullname}
numberOfLines={1}
>
{name}
{hasFullName && `${firstName} ${lastName}`}
{hasNickname && ` (${nickname}) `}
{isCurrentUser &&
<FormattedText
id='suggestion.mention.you'
defaultMessage='(you)'
/>}
</Text>
}
<Text
style={style.rowUsername}
numberOfLines={1}
>
{` @${username}`}
</Text>
</View>
</TouchableWithFeedback>
);
};
</TouchableWithFeedback>
);
}
}
AtMentionItem.propTypes = {
firstName: PropTypes.string,
lastName: PropTypes.string,
nickname: PropTypes.string,
onPress: PropTypes.func.isRequired,
userId: PropTypes.string.isRequired,
username: PropTypes.string,
isGuest: PropTypes.bool,
isBot: PropTypes.bool,
theme: PropTypes.object.isRequired,
isCurrentUser: PropTypes.bool.isRequired,
showFullName: PropTypes.string,
testID: PropTypes.string,
};
AtMentionItem.defaultProps = {
firstName: '',
lastName: '',
};
export default AtMentionItem;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
row: {
paddingVertical: 8,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
},
rowPicture: {
marginHorizontal: 8,
width: 20,
alignItems: 'center',
justifyContent: 'center',
},
rowUsername: {
fontSize: 13,
color: theme.centerChannelColor,
},
rowFullname: {
color: theme.centerChannelColor,
opacity: 0.6,
flex: 1,
},
};
});

View File

@@ -3,25 +3,27 @@
import {connect} from 'react-redux';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users';
import {isGuest} from '@utils/users';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import AtMentionItem from './at_mention_item';
import {isLandscape} from 'app/selectors/device';
import {isGuest} from 'app/utils/users';
function mapStateToProps(state, ownProps) {
const user = getUser(state, ownProps.userId);
const config = getConfig(state);
return {
firstName: user.first_name,
lastName: user.last_name,
nickname: user.nickname,
username: user.username,
showFullName: config.ShowFullName,
isBot: Boolean(user.is_bot),
isGuest: isGuest(user),
theme: getTheme(state),
isLandscape: isLandscape(state),
isCurrentUser: getCurrentUserId(state) === user.id,
};
}

View File

@@ -7,14 +7,14 @@ import {
Keyboard,
Platform,
View,
ViewPropTypes,
} from 'react-native';
import {DeviceTypes} from '@constants';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {emptyFunction} from '@utils/general';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {DeviceTypes} from 'app/constants';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {emptyFunction} from 'app/utils/general';
import AtMention from './at_mention';
import ChannelMention from './channel_mention';
import EmojiSuggestion from './emoji_suggestion';
@@ -35,10 +35,8 @@ export default class Autocomplete extends PureComponent {
valueEvent: PropTypes.string,
cursorPositionEvent: PropTypes.string,
nestedScrollEnabled: PropTypes.bool,
expandDown: PropTypes.bool,
onVisible: PropTypes.func,
offsetY: PropTypes.number,
onKeyboardOffsetChanged: PropTypes.func,
style: ViewPropTypes.style,
};
static defaultProps = {
@@ -47,8 +45,6 @@ export default class Autocomplete extends PureComponent {
enableDateSuggestion: false,
nestedScrollEnabled: false,
onVisible: emptyFunction,
onKeyboardOffsetChanged: emptyFunction,
offsetY: 80,
};
static getDerivedStateFromProps(props, state) {
@@ -151,12 +147,10 @@ export default class Autocomplete extends PureComponent {
keyboardDidShow = (e) => {
const {height} = e.endCoordinates;
this.setState({keyboardOffset: height});
this.props.onKeyboardOffsetChanged(height);
};
keyboardDidHide = () => {
this.setState({keyboardOffset: 0});
this.props.onKeyboardOffsetChanged(0);
};
maxListHeight() {
@@ -170,44 +164,41 @@ export default class Autocomplete extends PureComponent {
offset = 90;
}
maxHeight = (this.props.deviceHeight / 2) - offset;
maxHeight = this.props.deviceHeight - offset - this.state.keyboardOffset;
}
return maxHeight;
}
render() {
const {atMentionCount, channelMentionCount, emojiCount, commandCount, dateCount, cursorPosition, value} = this.state;
const {theme, isSearch, offsetY} = this.props;
const {theme, isSearch, expandDown} = this.props;
const style = getStyleFromTheme(theme);
const maxListHeight = this.maxListHeight();
const wrapperStyles = [];
const containerStyles = [style.borders];
if (Platform.OS === 'ios') {
wrapperStyles.push(style.shadow);
}
const containerStyles = [];
if (isSearch) {
wrapperStyles.push(style.base, style.searchContainer, {height: maxListHeight});
wrapperStyles.push(style.base, style.searchContainer);
containerStyles.push(style.content);
} else {
const containerStyle = {bottom: offsetY};
const containerStyle = expandDown ? style.containerExpandDown : style.container;
containerStyles.push(style.base, containerStyle);
}
// Hide when there are no active autocompletes
if (atMentionCount + channelMentionCount + emojiCount + commandCount + dateCount === 0) {
wrapperStyles.push(style.hidden);
containerStyles.push(style.hidden);
// We always need to render something, but we only draw the borders when we have results to show
const {atMentionCount, channelMentionCount, emojiCount, commandCount, dateCount, cursorPosition, value} = this.state;
if (atMentionCount + channelMentionCount + emojiCount + commandCount + dateCount > 0) {
if (this.props.isSearch) {
wrapperStyles.push(style.bordersSearch);
} else {
containerStyles.push(style.borders);
}
}
const maxListHeight = this.maxListHeight();
return (
<View
style={wrapperStyles}
edges={['left', 'right']}
>
<View style={wrapperStyles}>
<View
testID='autocomplete'
ref={this.containerRef}
style={containerStyles}
>
@@ -264,37 +255,39 @@ export default class Autocomplete extends PureComponent {
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
base: {
left: 8,
left: 0,
overflow: 'hidden',
position: 'absolute',
right: 8,
right: 0,
},
borders: {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
overflow: 'hidden',
borderRadius: 4,
borderBottomWidth: 0,
},
hidden: {
display: 'none',
bordersSearch: {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
},
container: {
bottom: 0,
},
containerExpandDown: {
top: 0,
},
content: {
flex: 1,
},
searchContainer: {
flex: 1,
...Platform.select({
android: {
top: 42,
top: 46,
},
ios: {
top: 55,
top: 44,
},
}),
},
shadow: {
shadowColor: '#000',
shadowOpacity: 0.12,
shadowRadius: 8,
shadowOffset: {
width: 0,
height: 8,
},
},
};
});

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
export default class AutocompleteDivider extends PureComponent {
static propTypes = {
theme: PropTypes.object.isRequired,
};
render() {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
return (
<View style={style.divider}/>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
divider: {
height: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
},
};
});

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