forked from Ivasoft/mattermost-mobile
Compare commits
75 Commits
release-1.
...
v1.22.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9e5da40ec | ||
|
|
51050e31a9 | ||
|
|
9c00f51058 | ||
|
|
bfd50568cc | ||
|
|
5bef5c2da8 | ||
|
|
b064312e70 | ||
|
|
e82db7840f | ||
|
|
c4d8308802 | ||
|
|
b8b7d54185 | ||
|
|
8416525487 | ||
|
|
d7a85fe2de | ||
|
|
c1957ac6a0 | ||
|
|
90dba40a92 | ||
|
|
f8efcf73b7 | ||
|
|
ff7548251b | ||
|
|
e03b72861c | ||
|
|
94aa1be435 | ||
|
|
7bf3020490 | ||
|
|
880cbd9f3d | ||
|
|
390c30af00 | ||
|
|
e3fd88b7fb | ||
|
|
3ed078f4ce | ||
|
|
e7042e4907 | ||
|
|
9ae69d9b43 | ||
|
|
eb314d714a | ||
|
|
cdd7a54ef4 | ||
|
|
41ddb5cc1a | ||
|
|
aceef20257 | ||
|
|
a5f8b9bdcc | ||
|
|
18b7a3c0a1 | ||
|
|
7b75868101 | ||
|
|
24bd57ad3f | ||
|
|
b931733695 | ||
|
|
088d375ff2 | ||
|
|
4b06636d9b | ||
|
|
05db1aaa71 | ||
|
|
e94dfb5389 | ||
|
|
58b95e7609 | ||
|
|
da911a2b34 | ||
|
|
d842d7881a | ||
|
|
98dc141ee3 | ||
|
|
8d0cb0663b | ||
|
|
612d284cbb | ||
|
|
be727fec9e | ||
|
|
d0f059e1f9 | ||
|
|
df74521ff3 | ||
|
|
eff961d109 | ||
|
|
59f3633d94 | ||
|
|
31bd391e56 | ||
|
|
810f73e3ad | ||
|
|
14421ba8e9 | ||
|
|
5c4405278b | ||
|
|
067a5481ff | ||
|
|
c267b8dd13 | ||
|
|
ce325a4ab1 | ||
|
|
1f8e853e41 | ||
|
|
8887319324 | ||
|
|
e214039cee | ||
|
|
0a93ec134c | ||
|
|
a0b021d21d | ||
|
|
75aedb8aa1 | ||
|
|
60030defb8 | ||
|
|
50cc6f827e | ||
|
|
ac11b7fec3 | ||
|
|
c9575b464d | ||
|
|
a4284666a3 | ||
|
|
4d5422e98b | ||
|
|
a3783b1bf5 | ||
|
|
c31ff56149 | ||
|
|
93498a3ab5 | ||
|
|
6d10915aad | ||
|
|
79653ad814 | ||
|
|
13bb83c11c | ||
|
|
3f6e706fa1 | ||
|
|
5492cd5e46 |
@@ -1,507 +0,0 @@
|
||||
version: 2.1
|
||||
|
||||
executors:
|
||||
android:
|
||||
parameters:
|
||||
resource_class:
|
||||
default: large
|
||||
type: string
|
||||
environment:
|
||||
NODE_OPTIONS: --max_old_space_size=12000
|
||||
NODE_ENV: production
|
||||
BABEL_ENV: production
|
||||
docker:
|
||||
- image: circleci/android:api-27-node
|
||||
working_directory: ~/mattermost-mobile
|
||||
resource_class: <<parameters.resource_class>>
|
||||
|
||||
ios:
|
||||
environment:
|
||||
NODE_OPTIONS: --max_old_space_size=12000
|
||||
NODE_ENV: production
|
||||
BABEL_ENV: production
|
||||
macos:
|
||||
xcode: "11.0.0"
|
||||
working_directory: ~/mattermost-mobile
|
||||
shell: /bin/bash --login -o pipefail
|
||||
|
||||
commands:
|
||||
checkout-private:
|
||||
description: "Checkout the private repo with build env vars"
|
||||
steps:
|
||||
- add_ssh_keys:
|
||||
fingerprints:
|
||||
- "59:4d:99:5e:1c:6d:30:36:6d:60:76:88:ff:a7:ab:63"
|
||||
- run:
|
||||
name: Clone the mobile private repo
|
||||
command: git clone git@github.com:mattermost/mattermost-mobile-private.git ~/mattermost-mobile-private
|
||||
|
||||
fastlane-dependencies:
|
||||
description: "Get Fastlane dependencies"
|
||||
parameters:
|
||||
for:
|
||||
type: string
|
||||
steps:
|
||||
- ruby-setup
|
||||
- restore_cache:
|
||||
name: Restore Fastlane cache
|
||||
key: v1-gems-<< parameters.for >>-{{ checksum "fastlane/Gemfile.lock" }}-{{ arch }}
|
||||
- run:
|
||||
working_directory: fastlane
|
||||
name: Download Fastlane dependencies
|
||||
command: bundle install --path vendor/bundle
|
||||
- save_cache:
|
||||
name: Save Fastlane cache
|
||||
key: v1-gems-<< parameters.for >>-{{ checksum "fastlane/Gemfile.lock" }}-{{ arch }}
|
||||
paths:
|
||||
- fastlane/vendor/bundle
|
||||
|
||||
gradle-dependencies:
|
||||
description: "Get Gradle dependencies"
|
||||
steps:
|
||||
- restore_cache:
|
||||
name: Restore Gradle cache
|
||||
key: v1-gradle-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }}
|
||||
- run:
|
||||
working_directory: android
|
||||
name: Download Gradle dependencies
|
||||
command: ./gradlew dependencies
|
||||
- save_cache:
|
||||
name: Save Gradle cache
|
||||
paths:
|
||||
- ~/.gradle
|
||||
key: v1-gradle-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }}
|
||||
|
||||
assets:
|
||||
description: "Generate app assets"
|
||||
steps:
|
||||
- restore_cache:
|
||||
name: Restore assets cache
|
||||
key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }}
|
||||
- run:
|
||||
name: Generate assets
|
||||
command: make dist/assets
|
||||
- save_cache:
|
||||
name: Save assets cache
|
||||
key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }}
|
||||
paths:
|
||||
- dist
|
||||
|
||||
npm-dependencies:
|
||||
description: "Get JavaScript dependencies"
|
||||
steps:
|
||||
- restore_cache:
|
||||
name: Restore npm cache
|
||||
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
|
||||
- run:
|
||||
name: Getting JavaScript dependencies
|
||||
command: NODE_ENV=development npm install --ignore-scripts
|
||||
- save_cache:
|
||||
name: Save npm cache
|
||||
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
|
||||
paths:
|
||||
- node_modules
|
||||
- run:
|
||||
name: "Run post install scripts"
|
||||
command: make post-install
|
||||
|
||||
pods-dependencies:
|
||||
description: "Get cocoapods dependencies"
|
||||
steps:
|
||||
- restore_cache:
|
||||
name: Restore cocoapods specs and pods
|
||||
key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
|
||||
- run:
|
||||
name: Getting cocoapods dependencies
|
||||
working_directory: ios
|
||||
command: pod install
|
||||
- save_cache:
|
||||
name: Save cocoapods specs and pods cache
|
||||
key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }}
|
||||
paths:
|
||||
- ios/Pods
|
||||
- ~/.cocoapods
|
||||
|
||||
build-android:
|
||||
description: "Build the android app"
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/mattermost-mobile
|
||||
- checkout-private
|
||||
- npm-dependencies
|
||||
- assets
|
||||
- fastlane-dependencies:
|
||||
for: android
|
||||
- gradle-dependencies
|
||||
- run:
|
||||
name: Append Keystore to build Android
|
||||
command: |
|
||||
cp ~/mattermost-mobile-private/android/${STORE_FILE} android/app/${STORE_FILE}
|
||||
echo "" | tee -a android/gradle.properties > /dev/null
|
||||
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:
|
||||
working_directory: fastlane
|
||||
name: Run fastlane to build android
|
||||
no_output_timeout: 30m
|
||||
command: bundle exec fastlane android build
|
||||
|
||||
build-ios:
|
||||
description: "Build the iOS app"
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/mattermost-mobile
|
||||
- npm-dependencies
|
||||
- pods-dependencies
|
||||
- assets
|
||||
- fastlane-dependencies:
|
||||
for: ios
|
||||
- run:
|
||||
working_directory: fastlane
|
||||
name: Run fastlane to build iOS
|
||||
no_output_timeout: 30m
|
||||
command: |
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
|
||||
bundle exec fastlane ios build
|
||||
|
||||
deploy-to-store:
|
||||
description: "Deploy build to store"
|
||||
parameters:
|
||||
task:
|
||||
type: string
|
||||
target:
|
||||
type: string
|
||||
file:
|
||||
type: string
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: ~/
|
||||
- run:
|
||||
name: <<parameters.task>>
|
||||
working_directory: fastlane
|
||||
command: bundle exec fastlane <<parameters.target>> deploy file:$HOME/mattermost-mobile/<<parameters.file>>
|
||||
|
||||
persist:
|
||||
description: "Persist mattermost-mobile directory"
|
||||
steps:
|
||||
- persist_to_workspace:
|
||||
root: ~/
|
||||
paths:
|
||||
- mattermost-mobile*
|
||||
|
||||
save:
|
||||
description: "Save binaries artifacts"
|
||||
parameters:
|
||||
filename:
|
||||
type: string
|
||||
steps:
|
||||
- store_artifacts:
|
||||
path: ~/mattermost-mobile/<<parameters.filename>>
|
||||
|
||||
ruby-setup:
|
||||
steps:
|
||||
- run:
|
||||
name: Set Ruby Version
|
||||
command: echo "ruby-2.6.3" > ~/.ruby-version
|
||||
|
||||
jobs:
|
||||
test:
|
||||
working_directory: ~/mattermost-mobile
|
||||
docker:
|
||||
- image: circleci/node:10
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/mattermost-mobile
|
||||
- npm-dependencies
|
||||
- assets
|
||||
- run:
|
||||
name: Check styles
|
||||
command: npm run check
|
||||
- run:
|
||||
name: Running Tests
|
||||
command: npm test
|
||||
- run:
|
||||
name: Check i18n
|
||||
command: make i18n-extract-ci
|
||||
|
||||
build-android-beta:
|
||||
executor: android
|
||||
steps:
|
||||
- build-android
|
||||
- persist
|
||||
- save:
|
||||
filename: "Mattermost_Beta.apk"
|
||||
|
||||
build-android-release:
|
||||
executor: android
|
||||
steps:
|
||||
- build-android
|
||||
- persist
|
||||
- save:
|
||||
filename: "Mattermost.apk"
|
||||
|
||||
build-android-pr:
|
||||
executor: android
|
||||
environment:
|
||||
BRANCH_TO_BUILD: ${CIRCLE_BRANCH}
|
||||
steps:
|
||||
- build-android
|
||||
- save:
|
||||
filename: "Mattermost_Beta.apk"
|
||||
|
||||
build-android-unsigned:
|
||||
executor: android
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/mattermost-mobile
|
||||
- npm-dependencies
|
||||
- assets
|
||||
- fastlane-dependencies:
|
||||
for: android
|
||||
- gradle-dependencies
|
||||
- run:
|
||||
working_directory: fastlane
|
||||
name: Run fastlane to build unsigned android
|
||||
no_output_timeout: 30m
|
||||
command: bundle exec fastlane android unsigned
|
||||
- persist
|
||||
- save:
|
||||
filename: "Mattermost-unsigned.apk"
|
||||
|
||||
build-ios-beta:
|
||||
executor: ios
|
||||
steps:
|
||||
- build-ios
|
||||
- persist
|
||||
- save:
|
||||
filename: "Mattermost_Beta.ipa"
|
||||
|
||||
build-ios-release:
|
||||
executor: ios
|
||||
steps:
|
||||
- build-ios
|
||||
- persist
|
||||
- save:
|
||||
filename: "Mattermost.ipa"
|
||||
|
||||
build-ios-pr:
|
||||
executor: ios
|
||||
environment:
|
||||
BRANCH_TO_BUILD: ${CIRCLE_BRANCH}
|
||||
steps:
|
||||
- build-ios
|
||||
- save:
|
||||
filename: "Mattermost_Beta.ipa"
|
||||
|
||||
build-ios-unsigned:
|
||||
executor: ios
|
||||
steps:
|
||||
- checkout:
|
||||
path: ~/mattermost-mobile
|
||||
- ruby-setup
|
||||
- npm-dependencies
|
||||
- pods-dependencies
|
||||
- assets
|
||||
- fastlane-dependencies:
|
||||
for: ios
|
||||
- run:
|
||||
working_directory: fastlane
|
||||
name: Run fastlane to build unsigned iOS
|
||||
no_output_timeout: 30m
|
||||
command: |
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 brew install watchman
|
||||
bundle exec fastlane ios unsigned
|
||||
- persist_to_workspace:
|
||||
root: ~/
|
||||
paths:
|
||||
- mattermost-mobile/Mattermost-unsigned.ipa
|
||||
- save:
|
||||
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: 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: Mattermost_Beta.apk
|
||||
|
||||
deploy-ios-release:
|
||||
executor: ios
|
||||
steps:
|
||||
- ruby-setup
|
||||
- deploy-to-store:
|
||||
task: "Deploy to TestFlight"
|
||||
target: ios
|
||||
file: Mattermost.ipa
|
||||
|
||||
deploy-ios-beta:
|
||||
executor: ios
|
||||
steps:
|
||||
- ruby-setup
|
||||
- deploy-to-store:
|
||||
task: "Deploy to TestFlight"
|
||||
target: ios
|
||||
file: Mattermost_Beta.ipa
|
||||
|
||||
github-release:
|
||||
executor:
|
||||
name: android
|
||||
resource_class: medium
|
||||
steps:
|
||||
- ruby-setup
|
||||
- attach_workspace:
|
||||
at: ~/
|
||||
- run:
|
||||
name: Create GitHub release
|
||||
working_directory: fastlane
|
||||
command: bundle exec fastlane github
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build:
|
||||
jobs:
|
||||
- test
|
||||
|
||||
- build-android-release:
|
||||
context: mattermost-mobile-android-release
|
||||
requires:
|
||||
- test
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /^build-\d+$/
|
||||
- /^build-android-\d+$/
|
||||
- /^build-android-release-\d+$/
|
||||
- deploy-android-release:
|
||||
context: mattermost-mobile-android-release
|
||||
requires:
|
||||
- build-android-release
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /^build-\d+$/
|
||||
- /^build-android-\d+$/
|
||||
- /^build-android-release-\d+$/
|
||||
|
||||
- build-android-beta:
|
||||
context: mattermost-mobile-android-beta
|
||||
requires:
|
||||
- test
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /^build-\d+$/
|
||||
- /^build-android-\d+$/
|
||||
- /^build-android-beta-\d+$/
|
||||
- deploy-android-beta:
|
||||
context: mattermost-mobile-android-beta
|
||||
requires:
|
||||
- build-android-beta
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /^build-\d+$/
|
||||
- /^build-android-\d+$/
|
||||
- /^build-android-beta-\d+$/
|
||||
|
||||
- build-ios-release:
|
||||
context: mattermost-mobile-ios-release
|
||||
requires:
|
||||
- test
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /^build-\d+$/
|
||||
- /^build-ios-\d+$/
|
||||
- /^build-ios-release-\d+$/
|
||||
- deploy-ios-release:
|
||||
context: mattermost-mobile-ios-release
|
||||
requires:
|
||||
- build-ios-release
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /^build-\d+$/
|
||||
- /^build-ios-\d+$/
|
||||
- /^build-ios-release-\d+$/
|
||||
|
||||
- build-ios-beta:
|
||||
context: mattermost-mobile-ios-beta
|
||||
requires:
|
||||
- test
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /^build-\d+$/
|
||||
- /^build-ios-\d+$/
|
||||
- /^build-ios-beta-\d+$/
|
||||
- deploy-ios-beta:
|
||||
context: mattermost-mobile-ios-beta
|
||||
requires:
|
||||
- build-ios-beta
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /^build-\d+$/
|
||||
- /^build-ios-\d+$/
|
||||
- /^build-ios-beta-\d+$/
|
||||
|
||||
- build-android-pr:
|
||||
context: mattermost-mobile-android-pr
|
||||
requires:
|
||||
- test
|
||||
filters:
|
||||
branches:
|
||||
only: /^build-pr-.*/
|
||||
- build-ios-pr:
|
||||
context: mattermost-mobile-ios-pr
|
||||
requires:
|
||||
- test
|
||||
filters:
|
||||
branches:
|
||||
only: /^build-pr-.*/
|
||||
|
||||
- build-android-unsigned:
|
||||
context: mattermost-mobile-unsigned
|
||||
requires:
|
||||
- test
|
||||
filters:
|
||||
tags:
|
||||
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
|
||||
branches:
|
||||
only: unsigned
|
||||
- build-ios-unsigned:
|
||||
context: mattermost-mobile-unsigned
|
||||
requires:
|
||||
- test
|
||||
filters:
|
||||
tags:
|
||||
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
|
||||
branches:
|
||||
only: unsigned
|
||||
- github-release:
|
||||
context: mattermost-mobile-unsigned
|
||||
requires:
|
||||
- build-android-unsigned
|
||||
- build-ios-unsigned
|
||||
filters:
|
||||
tags:
|
||||
only: /^v(\d+\.)(\d+\.)(\d+)(.*)?$/
|
||||
branches:
|
||||
only: unsigned
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"extends": [
|
||||
"plugin:mattermost/react",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"mattermost",
|
||||
"@typescript-eslint"
|
||||
"./node_modules/eslint-config-mattermost/.eslintrc.json",
|
||||
"./node_modules/eslint-config-mattermost/.eslintrc-react.json"
|
||||
],
|
||||
"settings": {
|
||||
"react": {
|
||||
@@ -24,17 +18,7 @@
|
||||
"rules": {
|
||||
"global-require": 0,
|
||||
"react/display-name": [2, { "ignoreTranspilerName": false }],
|
||||
"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,
|
||||
"@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
|
||||
"react/jsx-filename-extension": [2, {"extensions": [".js"]}]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
78
.flowconfig
78
.flowconfig
@@ -5,24 +5,26 @@
|
||||
; Ignore "BUCK" generated dirs
|
||||
<PROJECT_ROOT>/\.buckd/
|
||||
|
||||
; Ignore unexpected extra "@providesModule"
|
||||
.*/node_modules/.*/node_modules/fbjs/.*
|
||||
|
||||
; Ignore duplicate module providers
|
||||
; For RN Apps installed via npm, "Libraries" folder is inside
|
||||
; "node_modules/react-native" but in the source repo it is in the root
|
||||
.*/Libraries/react-native/React.js
|
||||
|
||||
; Ignore polyfills
|
||||
node_modules/react-native/Libraries/polyfills/.*
|
||||
.*/Libraries/polyfills/.*
|
||||
|
||||
; These should not be required directly
|
||||
; require from fbjs/lib instead: require('fbjs/lib/warning')
|
||||
node_modules/warning/.*
|
||||
|
||||
; Flow doesn't support platforms
|
||||
.*/Libraries/Utilities/LoadingView.js
|
||||
|
||||
[untyped]
|
||||
.*/node_modules/@react-native-community/cli/.*/.*
|
||||
; Ignore metro
|
||||
.*/node_modules/metro/.*
|
||||
|
||||
[include]
|
||||
|
||||
[libs]
|
||||
node_modules/react-native/interface.js
|
||||
node_modules/react-native/Libraries/react-native/react-native-interface.js
|
||||
node_modules/react-native/flow/
|
||||
node_modules/react-native/flow-github/
|
||||
|
||||
[options]
|
||||
emoji=true
|
||||
@@ -30,45 +32,39 @@ emoji=true
|
||||
esproposal.optional_chaining=enable
|
||||
esproposal.nullish_coalescing=enable
|
||||
|
||||
module.file_ext=.js
|
||||
module.file_ext=.json
|
||||
module.file_ext=.ios.js
|
||||
module.system=haste
|
||||
module.system.haste.use_name_reducers=true
|
||||
# get basename
|
||||
module.system.haste.name_reducers='^.*/\([a-zA-Z0-9$_.-]+\.js\(\.flow\)?\)$' -> '\1'
|
||||
# strip .js or .js.flow suffix
|
||||
module.system.haste.name_reducers='^\(.*\)\.js\(\.flow\)?$' -> '\1'
|
||||
# strip .ios suffix
|
||||
module.system.haste.name_reducers='^\(.*\)\.ios$' -> '\1'
|
||||
module.system.haste.name_reducers='^\(.*\)\.android$' -> '\1'
|
||||
module.system.haste.name_reducers='^\(.*\)\.native$' -> '\1'
|
||||
module.system.haste.paths.blacklist=.*/__tests__/.*
|
||||
module.system.haste.paths.blacklist=.*/__mocks__/.*
|
||||
module.system.haste.paths.blacklist=<PROJECT_ROOT>/node_modules/react-native/Libraries/Animated/src/polyfills/.*
|
||||
module.system.haste.paths.whitelist=<PROJECT_ROOT>/node_modules/react-native/Libraries/.*
|
||||
|
||||
munge_underscores=true
|
||||
|
||||
module.name_mapper='^react-native/\(.*\)$' -> '<PROJECT_ROOT>/node_modules/react-native/\1'
|
||||
module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> '<PROJECT_ROOT>/node_modules/react-native/Libraries/Image/RelativeImageStub'
|
||||
module.name_mapper='^[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> 'RelativeImageStub'
|
||||
|
||||
module.file_ext=.js
|
||||
module.file_ext=.jsx
|
||||
module.file_ext=.json
|
||||
module.file_ext=.native.js
|
||||
|
||||
suppress_type=$FlowIssue
|
||||
suppress_type=$FlowFixMe
|
||||
suppress_type=$FlowFixMeProps
|
||||
suppress_type=$FlowFixMeState
|
||||
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(<VERSION>\\)? *\\(site=[a-z,_]*react_native\\(_ios\\)?_\\(oss\\|fb\\)[a-z,_]*\\)?)\\)
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(<VERSION>\\)? *\\(site=[a-z,_]*react_native\\(_ios\\)?_\\(oss\\|fb\\)[a-z,_]*\\)?)\\)?:? #[0-9]+
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(<VERSION>\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(<VERSION>\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)?:? #[0-9]+
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowFixedInNextDeploy
|
||||
suppress_comment=\\(.\\|\n\\)*\\$FlowExpectedError
|
||||
|
||||
[lints]
|
||||
sketchy-null-number=warn
|
||||
sketchy-null-mixed=warn
|
||||
sketchy-number=warn
|
||||
untyped-type-import=warn
|
||||
nonstrict-import=warn
|
||||
deprecated-type=warn
|
||||
unsafe-getters-setters=warn
|
||||
inexact-spread=warn
|
||||
unnecessary-invariant=warn
|
||||
signature-verification-failure=warn
|
||||
deprecated-utility=error
|
||||
|
||||
[strict]
|
||||
deprecated-type
|
||||
nonstrict-import
|
||||
sketchy-null
|
||||
unclear-type
|
||||
unsafe-getters-setters
|
||||
untyped-import
|
||||
untyped-type-import
|
||||
|
||||
[version]
|
||||
^0.113.0
|
||||
^0.92.0
|
||||
|
||||
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -1,4 +1 @@
|
||||
*.pbxproj -text
|
||||
|
||||
# specific for windows script files
|
||||
*.bat text eol=crlf
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -22,6 +22,7 @@ build/
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata
|
||||
xcshareddata
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
DerivedData
|
||||
@@ -30,8 +31,7 @@ DerivedData
|
||||
*.apk
|
||||
*.xcuserstate
|
||||
project.xcworkspace
|
||||
ios/Pods
|
||||
.podinstall
|
||||
xcshareddata/
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
@@ -39,10 +39,6 @@ ios/Pods
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
android/app/bin
|
||||
.settings
|
||||
.project
|
||||
.classpath
|
||||
|
||||
# node.js
|
||||
#
|
||||
@@ -87,7 +83,10 @@ ios/sentry.properties
|
||||
# Testing
|
||||
.nyc_output
|
||||
coverage
|
||||
.tmp
|
||||
|
||||
# Pods
|
||||
.podinstall
|
||||
ios/Pods/
|
||||
|
||||
# Bundle artifact
|
||||
*.jsbundle
|
||||
|
||||
535
CHANGELOG.md
535
CHANGELOG.md
@@ -1,513 +1,10 @@
|
||||
# Mattermost Mobile Apps Changelog
|
||||
|
||||
## 1.31.0 Release
|
||||
- Release Date: May 16, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- **Upgrade to server version v5.19 or later is required.** Support for server [Extended Support Release](https://docs.mattermost.com/administration/extended-support-release.html) (ESR) 5.9 has ended and upgrading to server ESR v5.19 or later is required. As we innovate and offer newer versions of our mobile apps, we maintain backwards compatibility only with supported server versions. Users who upgrade to the newest mobile apps while being connected to an unsupported server version can be exposed to compatibility issues, which can cause crashes or severe bugs that break core functionality of the app. See [this blog post](https://mattermost.com/blog/support-for-esr-5-9-has-ended/) for more details.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Improvements
|
||||
- Improved network reliability and channel switching time for unread channels by fetching new posts as soon as the app reconnects.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### All apps
|
||||
- Fixed an issue where slash commands with long descriptions had their description text truncated in the slash command autocomplete.
|
||||
- Fixed an issue where users could not swipe up to dismiss in-app push notifications.
|
||||
- Fixed an issue where the username that created the webhook was shown on webhook posts instead of the name of the bot.
|
||||
- Fixed an issue where posts on the same thread appeared to be from different threads since the "...commented on [Thread Title]" was shown on all posts in the thread.
|
||||
- Fixed an issue where the system message for "Edit Channel Purpose" rendered markdown.
|
||||
|
||||
#### iOS specific
|
||||
- Fixed an issue where code block numbering was obstructed by the iPhone's notch.
|
||||
- Fixed an issue where the search text box was partially obstructed in landscape mode.
|
||||
- Fixed an issue where using `Share...` option to post highlighted text to the app threw an error.
|
||||
- Fixed an issue where the "back" button color was incorrect when transitioning from Thread screen to Channel screen.
|
||||
- Fixed an issue where the keyboard flashed a darker color when opening Keywords from **Settings > Notifications > Mentions and replies**.
|
||||
|
||||
#### Android specific
|
||||
- Fixed an issue where the keyboard did not close after editing a message.
|
||||
|
||||
## 1.30.1 Release
|
||||
- Release Date: April 24, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### All apps
|
||||
- Fixed an issue with repeated forced logouts.
|
||||
- Fixed an issue where channels appeared as read-only when opening the app.
|
||||
- Fixed an issue where users were unable to log in if ``ExperimentalStrictCSRFEnforcement`` setting was enabled.
|
||||
- A clean install may be required for the fix to take effect by uninstalling v1.30.0 (Build 285) and then installing v1.30.1 (Build 287).
|
||||
- Fixed an issue where a "No internet connection" error occurred when deleting documents and data.
|
||||
|
||||
#### iOS specific
|
||||
- Fixed an issue where Mattermost app crashed when Enterprise mobility management (EMM) was enabled.
|
||||
|
||||
#### Android specific
|
||||
- Fixed an issue where using backspace out of a conversation thread or a channel caused a forced logout.
|
||||
- Fixed an issue where a video upload attempt failed with an error.
|
||||
|
||||
## 1.30.0 Release
|
||||
- Release Date: April 16, 2020
|
||||
- Server Versions Supported: Server v5.19+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
Mattermost Mobile App v1.30.0 contains a high level security fix. [Upgrading](http://docs.mattermost.com/administration/upgrade.html) is recommended. Details will be posted on our [security updates page](https://about.mattermost.com/security-updates/) 30 days after release as per the [Mattermost Responsible Disclosure Policy](https://www.mattermost.org/responsible-disclosure-policy/).
|
||||
|
||||
**Note:** v5.9.0 as our Extended Support Release (ESR) is coming to the end of its lifecycle and upgrading to 5.19.0 ESR or a later version is highly recommended. v5.19.0 will continue to be our current ESR until October 15, 2020. [Learn more in our forum post](https://forum.mattermost.org/t/upcoming-extended-support-release-updates/8526).
|
||||
|
||||
**Note:** [The Channel Moderation Settings feature](https://docs.mattermost.com/deployment/advanced-permissions.html#channel-moderation-beta-e20) released in v5.22.0 is supported on mobile app versions v1.30 and later. In earlier versions of the mobile app, users who attempt to post or react to posts without proper permissions will see an error.
|
||||
|
||||
### Improvements
|
||||
- Significantly improved Android performance, including how quickly posts in the center screen are displayed.
|
||||
- Added support for different interactive message button styles on mobile.
|
||||
- Enter key on hardware Android keyboard now posts a message.
|
||||
- The statuses of those users that are in the Direct Message list are now fetched when opening the app and on login.
|
||||
- Added "Unarchive Channel" option to the channel info screen.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### All apps
|
||||
- Fixed an issue where the modal popped down when attempting to scroll down to see if there are more emoji.
|
||||
- Fixed a few crash issues.
|
||||
- Fixed an issue where the navigation bar tucked under status bar when using photo or camera post icons in landscape.
|
||||
- Removed mark as unread option from post menus for archived channels.
|
||||
- Fixed an issue where the "Refreshing message failed" error was shown when starting a Direct Message with a new user without a verified email.
|
||||
- Fixed an issue where Markdown tables was rendering in full in the center channel on larger screen sizes.
|
||||
- Made the name displayed consistent with teammate display name setting.
|
||||
- Fixed some selected emojis in autocomplete from rendering properly when posted.
|
||||
|
||||
#### iOS specific
|
||||
- Fixed an issue on iOS where the navigation bar tucked under status bar when using photo or camera post icons in landscape.
|
||||
- Fixed an issue on iOS where Automatic Replies custom message text box was obstructed by the iPhone's notch.
|
||||
- Fixed an issue on iOS where double dashes in mobile inside a code block got converted to emdash.
|
||||
|
||||
#### Android specific
|
||||
- Fixed an issue on Android where downloading a file or video was not reporting progress.
|
||||
- Fixed an issue on Android that was preventing to share content through the share extension.
|
||||
|
||||
## 1.29.0 Release
|
||||
- Release Date: March 16, 2020
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
**Note:** The persisted sidebar on Android tablets was removed in order to significantly improve the mobile app performance.
|
||||
|
||||
**Note:** An issue was fixed where a user's status was set as online when replying to a message from a push notification. This fix only works in combination with server v5.20.0+.
|
||||
|
||||
### Improvements
|
||||
- Significantly improved how quickly channels load when you open the app and when you switch between them.
|
||||
- Set all requests timeouts to a maximum of 5 seconds to improve reliability on bad networks.
|
||||
- Changed "Copy Permalink" to "Copy Link" for readability.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where downloaded files on Android had the words `download successful` appended to their filenames, preventing the file from being opened until it was renamed in the file manager.
|
||||
- Fixed a silent crash on Android when receiving a push notification.
|
||||
- Fixed an issue on Android where users could not swipe to close sidebar unless the gesture was initiated outside of the sidebar.
|
||||
- Fixed an issue where channels drawers were partially shown with orientation change on iOS RN61.
|
||||
- Fixed an issue on iOS where the message box obstructed the bottom part of the message when opened from the notification banner.
|
||||
- Fixed an issue where switching teams showed the center channel from the old team until the new team's channel data got loaded.
|
||||
- Fixed an issue where users could not post messages after returning from an archived channel.
|
||||
- Fixed an issue where user experienced infinite scrolling when viewing all public joinable/archived channels.
|
||||
- Fixed an issue where archived channels membership was lost on the client.
|
||||
- Fixed an issue on iOS where the channel intro scrolled past the top of the channel.
|
||||
- Fixed an issue on Android where inline custom emojis did not display in portrait mode.
|
||||
- Fixed an issue where markdown tables did not display all rows in a post when it had multiple heights.
|
||||
- Fixed an issue where deleting documents and data caused a flash of the background when the app reloaded.
|
||||
- Fixed an issue where tall and thin image attachments got pushed to the left instead of appearing centered.
|
||||
|
||||
### Known Issues
|
||||
- Some gender neutral emojis don't render as jumbo emojis.
|
||||
|
||||
## 1.28.0 Release
|
||||
- Release Date: February 16, 2020
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Highlights
|
||||
|
||||
#### UI/UX Improvements to the Post Draft Area
|
||||
- Links added to facilitate easier access to common functions:
|
||||
- finding channel members for @mentioning;
|
||||
- finding and referencing slash commands;
|
||||
- attaching photos and videos;
|
||||
- accessing the camera
|
||||
|
||||
#### Deep Linking
|
||||
- Links to posts in email notifications now launch to a browser landing page with option to open in the Mobile app.
|
||||
|
||||
### Improvements
|
||||
- Removed markdown rendering from Channel Purpose in channel info screen.
|
||||
- Improved channel info transition so that it opens up as a modal rather than as a drawer from the right.
|
||||
- Clicking on the time in the iOS status bar now scrolls up the center channel.
|
||||
- Improved the sliding behaviour of the left-hand sidebar on iOS.
|
||||
- Added more responsiveness to markdown tables.
|
||||
- User's own username with a suffix 'you' is now shown in the username autocomplete.
|
||||
- Improved sorting of emojis in the emoji picker so that thumbsup is sorted first, then thumbsdown, and then custom emoji.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue on Android where the app displayed an incorrect timestamp when the experimental Timezone setting was disabled.
|
||||
- Fixed an issue where combined system messages with many users listed hid posts above them.
|
||||
- Fixed an issue on iOS where the app crashed when pasting a GIF via the keyboard.
|
||||
- Fixed an issue where explicit links to teams and channels on the same server currently logged in to didn't switch to that team and channel.
|
||||
- Fixed an issue where the keyboard glitched when returning to the main channel view after viewing a code block in the right-hand side.
|
||||
- Fixed an issue with default boolean values in interactive dialogs.
|
||||
|
||||
### Known Issues
|
||||
- Markdown tables are missing a header colour.
|
||||
|
||||
## 1.27.1 Release
|
||||
- Release Date: January 21, 2020
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where all previously auto-closed Direct Message channels were listed in the channel sidebar.
|
||||
- Fixed a regression affecting webapp and mobile apps where some users were experiencing client-side performance issues. This was mainly affecting users with more than 100 channels listed in the channel sidebar and with channels sorted alphabetically.
|
||||
|
||||
## 1.27.0 Release
|
||||
- Release Date: January 16, 2020
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where flaky networks caused users to miss messages when at the top of the channel.
|
||||
- Fixed an issue where uploading image attachments in the mobile app was not working in some cases.
|
||||
- Fixed an issue where joining a user's first team from the mobile apps failed.
|
||||
- Fixed an issue where an unexpected `More New Messages Above` line appeared when marking a first post as unread in a Direct Message or Group Message channel.
|
||||
- Fixed an issue where disagreeing with custom Terms of Service gives users a glimpse of the app.
|
||||
- Fixed an issue on Android where the Back button did not dismiss the modal before dismissing the sidebar.
|
||||
- Fixed an issue where a message draft was lost after attempting to post an invalid slash command.
|
||||
- Fixed an issue where timestamps on 12-hour format had a leading zero.
|
||||
- Fixed an issue where the display name of a post was truncated even when there was enough space to render it on landscape.
|
||||
- Fixed an issue where the post input field icon was mis-aligned.
|
||||
- Fixed an issue where system message mentions were not at 100% opacity compared to non-system messages.
|
||||
|
||||
### Known Issues
|
||||
- Text box obstructs bottom part of messages in Direct Message channels when opened from a notification banner. [MM-21276](https://mattermost.atlassian.net/browse/MM-21276)
|
||||
|
||||
## 1.26.2 Release
|
||||
- Release Date: January 7, 2020
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue on iOS where the mobile app was not usable if ``inAppPincode`` was enabled.
|
||||
|
||||
## 1.26.1 Release
|
||||
- Release Date: December 20, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed a crash issue on Android and iOS on server versions prior to the v5.9.0 Extended Support Release (ESR).
|
||||
- Fixed a crash when connecting the WebSocket to a server with Cert Based Auth (CBA) enabled.
|
||||
|
||||
## 1.26.0 Release
|
||||
- Release Date: December 16, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgrading](http://docs.mattermost.com/administration/upgrade.html) is recommended. Details will be posted on our [security updates page](https://about.mattermost.com/security-updates/) 30 days after release as per the [Mattermost Responsible Disclosure Policy](https://www.mattermost.org/responsible-disclosure-policy/).
|
||||
|
||||
### Highlights
|
||||
|
||||
#### Improved Styling for File, Image and Video Attachments, Including In-line Image Thumbnails
|
||||
|
||||
#### Mark as Unread
|
||||
- With server v5.18 and above, users can stay on top of important messages with a new feature that allows marking posts as unread. After doing so, users will automatically land on the unread post the next time they click on the relevant channel.
|
||||
|
||||
#### Push Notification Message Contents Fetched from the Server on Receipt (E20)
|
||||
- Allows push notifications to be delivered showing the full message contents that are fetched from the server once the notification is delivered to the device. This means that Apple Push Notification Service (APNS) or Google Firebase Cloud Messaging (FCM) cannot read the message contents since only a unique message ID is sent in the notification payload.
|
||||
|
||||
#### Upgraded RN to v0.61
|
||||
|
||||
### Improvements
|
||||
- Added support for pasting other file types such as videos, PDFs and documents.
|
||||
- Added the option to convert public channels to private in the channel info screen.
|
||||
- Added support for reading the channel drawer button with voice-over.
|
||||
- Made usernames in system messages tappable.
|
||||
- Added an autocomplete to edit post screen.
|
||||
- Added a count for pinned posts icon.
|
||||
- Updated the channel name length character limit to 64 to match server.
|
||||
- Added an expand button to truncated markdown tables to improve discoverability of opening them in full screen.
|
||||
- Added an error message when trying to share too long text from share extension.
|
||||
- Improved behaviour where posts from different authors in the same thread appeared to be from different threads if separated by new message line.
|
||||
- Added support for native emojis in the emoji picker and autocomplete.
|
||||
- Removed reactions and file attachments from the long post view.
|
||||
- Large number of emoji reactions now wrap instead of introducing horizontal scroll.
|
||||
- Added support for a generic error message in interactive dialog responses.
|
||||
- Added the ability to disable attachment buttons and fields.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue on Android where the app slowed down when opening a channel with large number of animated emoji.
|
||||
- Fixed an issue where the app crashed when pasting a large file to the text box from the clipboard.
|
||||
- Fixed an issue where the app crashed when previewing large GIF files.
|
||||
- Fixed an issue where the app crashed when using the emoji category selector.
|
||||
- Fixed an issue where the app was not able to play YouTube videos.
|
||||
- Fixed an issue where images/videos could not be saved.
|
||||
- Fixed an issue where channels archived via the command line interface were still visible on the left-hand side and accessible on mobile apps.
|
||||
- Fixed an issue where the thread header in landscape view was wider than the main channel view header.
|
||||
- Fixed an issue where sidebar separator line was misaligned between Teams and Channel view.
|
||||
- Fixed an issue on iOS where the channel spinner appeared black on a dark theme.
|
||||
- Fixed an issue where an asterisk appeared on the "Nickname" and "Position" fields in Edit Profile screen even though nickname is not handled through the login provider.
|
||||
- Fixed an issue where the filtered list for emojis opened above the edit box and behind the channel header when adding an emoji to channel header using ``:emoji:``.
|
||||
|
||||
## 1.25.1 Release
|
||||
- Release Date: November 22, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed a crash issue on iOS when SSO cookies did not contain an expiration date during login.
|
||||
- Fixed a crash issue on Android caused by notification channels being unavailable in Android 7.
|
||||
- Fixed an issue on Android where Enterprise Mobility Management (EMM) blur app screen did not work.
|
||||
- Fixed an issue where changing team/channel when sharing several files closed the share dialog.
|
||||
|
||||
## 1.25.0 Release
|
||||
- Release Date: November 16, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where Mattermost monokai theme no longer worked properly on mobile apps.
|
||||
- Fixed an issue on Android where the notification badge count didn't update when using multiple channels.
|
||||
- Fixed an issue on Android where test notifications did not work properly.
|
||||
- Fixed an issue where "In-app" notifications caused the app badge count to get out of sync.
|
||||
- Fixed an issue on Android where email notification setting displayed was not updated when the setting was changed.
|
||||
- Fixed an issue where Favorite channels list didn't update if the app was running in the background.
|
||||
- Fixed an issue where the timezone setting did not update when changing it back to set automatically.
|
||||
- Fixed an issue on iOS where clicking on a hashtag from "recent mentions" (or flagged posts) returned the user to the channel instead of displaying hashtag search results.
|
||||
- Fixed an issue where tapping on a hashtag engaged a keyboard for a moment before displaying search results.
|
||||
- Fixed an issue where posts of the same thread appeared to be from different threads if separated by a new message line.
|
||||
- Fixed styling issues on iOS for Name, Purpose and Header information on the channel info screen.
|
||||
- Fixed styling issues with bot posts timestamps in search results and pinned posts.
|
||||
- Fixed styling issues on single sign-on screen in landscape view on iOS iPhone X and later.
|
||||
- Fixed styling issues on iOS for the Helper text on Settings screens.
|
||||
- Fixed an issue where the thread view header theme was inconsistent during transition back to main channel view.
|
||||
- Fixed an issue on iOS where the navigation bar tucked under the phone's status bar when switching orientation.
|
||||
- Fixed an issue on iOS where the keyboard flashed darker when Automatic Replies had been previously enabled.
|
||||
- Fixed an issue on Android where uploading pictures from storage or camera required unwanted permissions.
|
||||
- Fixed an issue where ``mobile.message_length.message`` did not match webapp's ``create_post.error_message``.
|
||||
|
||||
### Known Issues
|
||||
- App slows down when opening a channel with large number of animated emoji. [MM-15792](https://mattermost.atlassian.net/browse/MM-15792)
|
||||
|
||||
## 1.24.0 Release
|
||||
- Release Date: October 16, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Highlights
|
||||
|
||||
#### Sidebar UI/UX improvements
|
||||
- Improved usability and styling of the channel drawer.
|
||||
|
||||
### Improvements
|
||||
- Added the ability to paste images on input text box.
|
||||
- Added copy and paste protection managed configuration support for Android.
|
||||
- Added a confirmation dialog when posting a message with `@channel` and `@all`.
|
||||
- Added support for safe area in landscape view on iOS.
|
||||
- Changed recent date separators to read Today/Yesterday.
|
||||
- Added an autocomplete to the edit channel screen.
|
||||
- Emoji picker search now ignores the leading colon.
|
||||
- Added support for emoji not requiring a whitespace to render.
|
||||
- Added support for footer and footer_icon in message attachments.
|
||||
- Added a password type for interactive dialogs.
|
||||
- Added support for introductory markdown paragraph in interactive dialogs.
|
||||
- Added support for boolean elements in interactive dialogs.
|
||||
- Improved the permissions prompt if Mattermost doesn't have permission to the photo library.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where the notification badge could get out of sync when reading messages in another client.
|
||||
- Fixed an issue where the notification badge number did not reset when opening a push notification.
|
||||
- Fixed an issue where SafeArea insets were not working properly on new iPhone 11 models.
|
||||
- Fixed an issue where long press on a system message in an archived channel locked up the app.
|
||||
- Fixed an issue where tapping on a hashtag while replying to search results didn't open search correctly.
|
||||
- Fixed an issue where the channel list panel was missing for a user when they were added to a new team by another user.
|
||||
- Fixed an issue where once in a thread, pressing a channel link appeared to do nothing.
|
||||
- Fixed an issue where file previews could scroll to the left until all files were out of view.
|
||||
- Fixed an issue on iOS where user was unable to select an emoji from two rows on the bottom of the emoji picker.
|
||||
- Fixed an issue where duplicate pinned posts displayed after editing pinned post from Pinned Posts screen.
|
||||
- Fixed an issue where the reply arrow overlapped a posts's timestamp in some cases.
|
||||
- Fixed an issue where post textbox did not clear after using a slash command.
|
||||
- Fixed an issue where users were are not immediately removed from the mention auto-complete when those users were deactivated.
|
||||
- Fixed an issue where returning to a channel from a thread view could trigger a long-press menu that couldn't be dismissed.
|
||||
- Fixed an issue with a missing "(you)" suffix in the channel header of a self Direct Message.
|
||||
- Fixed an issue where the Connected banner got stuck open after the WebSocket was connected.
|
||||
- Fixed an issue where the text input area in Android Share extension did not use available space.
|
||||
- Fixed an issue where Windows dark theme was not consistent when viewing an archived channel.
|
||||
- Fixed an issue where interactive dialogs rendered out of safe area view on landscape orientation.
|
||||
- Fixed an issue where a themed "Delete Documents & Data" action flashed a white screen.
|
||||
|
||||
### Known Issues
|
||||
- App slows down when opening a channel with large number of animated emoji. [MM-15792](https://mattermost.atlassian.net/browse/MM-15792)
|
||||
|
||||
## 1.23.1 Release
|
||||
- Release Date: September 27, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed issues causing the app to crash on some devices.
|
||||
|
||||
## 1.23.0 Release
|
||||
- Release Date: September 16, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where some Giphy actions were not working in ephemeral posts on mobile.
|
||||
- Fixed an issue where users were unable to create new channels when "Combine all channel types" was selected.
|
||||
- Fixed an issue on Android EMM where a crash occurred when tapping **Go to Settings**.
|
||||
- Fixed an issue on iOS where the in-app "Date" localization persisted after server and user changed.
|
||||
- Fixed an issue where the download step was showing when previewing a video right after posting it.
|
||||
- Fixed an issue on Android where cancelling a video download twice in a row showed an error.
|
||||
- Fixed an issue where file attachment thumbnail/preview could fail to load and not be able to be reloaded.
|
||||
- Fixed an issue on Android where **Channel > Add Members > ADD** text changed to black.
|
||||
- Fixed an issue on iOS where the **Cancel** label text didn't fit in one line in German language.
|
||||
- Fixed an issue where longer than allowed reply posts kept showing a warning with every backspace.
|
||||
- Fixed an issue where there was a delay in search box and emoji content width change when switching to/from portrait/landscape view.
|
||||
- Fixed an issue where deactivated users did not appear in the "Jump to..." screen.
|
||||
- Fixed an issue where "@undefined has joined the channel" was shown instead of "Someone has joined the channel" when a user joined a channel that another user was viewing.
|
||||
- Fixed an issue on Android where the reply arrow was cut off in search results.
|
||||
- Fixed an issue where changing display theme from webapp didn't work properly on mobile.
|
||||
- Fixed an issue on iOS where a bot account icon style was broken.
|
||||
- Fixed an issue with an incorrect UI text for location of touch ID setting.
|
||||
|
||||
### Known Issues
|
||||
- App slows down when opening a channel with large number of animated emoji. [MM-15792](https://mattermost.atlassian.net/browse/MM-15792)
|
||||
- When users are deactivated, they are not immediately removed from the mention auto-complete. [MM-17953](https://mattermost.atlassian.net/browse/MM-17953)
|
||||
|
||||
## 1.22.1 Release
|
||||
- Release Date: August 23, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where the apps crashed when setting the language to Chinese Traditional.
|
||||
- Fixed an issue on Android where push notification receipt delivery failed due to invalid server URL.
|
||||
- Fixed an issue where the apps crashed when launched via a notification.
|
||||
- Fixed an issue where posts made while the app was closed did not appear until refresh.
|
||||
|
||||
## 1.22.0 Release
|
||||
- Release Date: August 16, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Highlights
|
||||
|
||||
#### Support for iOS13 and Android Q
|
||||
- Added support for iOS13 and Android Q which are to be released later this year.
|
||||
|
||||
### Improvements
|
||||
- Added support for Interactive Dialog with no elements.
|
||||
- Added a setting for tablets to enable or disable fixed sidebar.
|
||||
- Changed "about" section references to use the site name when it is configured in **System Console > Custom Branding > Site Name**.
|
||||
- Added support for plus-sign and period/dot in custom URL schemes.
|
||||
- Added "Edit profile" button to right-hand side menu and to users' own profile pop-over.
|
||||
- Message draft is now saved when closing the app.
|
||||
- Removing a link preview on webapp now also removes it on the mobile app.
|
||||
- Added ability to select and copy channel header text and purpose.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed a few mobile app crash / fatal error issues.
|
||||
- Fixed an issue where timestamps were off on Android.
|
||||
- Fixed an issue where contents of ephemeral posts from /giphy were not being displayed on mobile.
|
||||
- Fixed an issue where team/channel page dots at the bottom of left-hand side overlapped with the last Direct Message channel.
|
||||
- Fixed an issue where network reconnection incorrectly showed refreshing messages failed.
|
||||
- Fixed an issue with the channel sidebar theme colors not being respected on iPhone X.
|
||||
- Fixed an issue where "Message failed to send" had incorrect app badge behaviour.
|
||||
- Fixed an issue where a white screen was briefly shown after pressing "Send Message" when viewing a user's profile.
|
||||
- Fixed an issue on Android where using "Https" instead of "https" in the url of an image didn't show the preview.
|
||||
- Fixed an issue where the client ``setCSRFFromCookie`` did not look for subpaths when accessing cookies.
|
||||
- Fixed an issue where archived teams reappeared in selector.
|
||||
- Fixed an issue where users' profile picture and name did not get updated after websocket disconnect.
|
||||
|
||||
### Known Issues
|
||||
- App slows down when opening a channel with large number of animated emoji. [MM-15792](https://mattermost.atlassian.net/browse/MM-15792)
|
||||
- Some Giphy actions do not work in ephemeral posts. [MM-17842](https://mattermost.atlassian.net/browse/MM-17842)
|
||||
|
||||
## 1.21.2 Release
|
||||
- Release Date: August 1, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where the mobile apps logged out without a session expiry notification.
|
||||
|
||||
## 1.21.1 Release
|
||||
- Release Date: July 22, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue on Android where logging in using SSO failed when the Mattermost server was running on a subpath.
|
||||
|
||||
## 1.21.0 Release
|
||||
- Release Date: July 16, 2019
|
||||
- Server Versions Supported: Server v5.9+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
|
||||
@@ -536,7 +33,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: July 10, 2019
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
@@ -549,7 +46,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: June 21, 2019
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
@@ -562,7 +59,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: June 16, 2019
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
@@ -607,7 +104,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: May 16, 2019
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
@@ -630,7 +127,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: April 18, 2019
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
@@ -643,7 +140,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: April 16, 2019
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
- iPhone 5s devices and later with iOS 11+ is required.
|
||||
@@ -680,7 +177,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: March 20, 2019
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
- If **DisableLegacyMfa** setting in ``config.json`` is set to ``true`` and [multi-factor authentication](https://docs.mattermost.com/deployment/auth.html) is enabled, ensure your users have upgraded to mobile app version 1.17 or later. See [Important Upgrade Notes](https://docs.mattermost.com/administration/important-upgrade-notes.html) for more details.
|
||||
- If you are using an EMM provider via AppConfig, make sure to add two new settings, `useVPN` and `timeoutVPN`, to your AppConfig file. The settings were added for EMM connections using VPN on-demand - one to indicate if every request should wait for the VPN connection to be established, and another to set the timeout in seconds. See docs for more details on [setting AppConfig values](https://docs.mattermost.com/mobile/mobile-appconfig.html#mattermost-appconfig-values) for VPN support.
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
@@ -708,7 +205,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: February 21, 2019
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
|
||||
@@ -720,7 +217,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: February 16, 2019
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
@@ -747,7 +244,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: January 16, 2019
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
@@ -767,7 +264,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: December 28, 2018
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
@@ -779,7 +276,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: December 16, 2018
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
### Compatibility
|
||||
### Combatibility
|
||||
|
||||
- Mobile App v1.13+ is required for Mattermost Server v5.4+.
|
||||
- Android operating system 7+ [is required by Google](https://android-developers.googleblog.com/2017/12/improving-app-security-and-performance.html).
|
||||
@@ -814,7 +311,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: November 16, 2018
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported unless the user installs the CA certificate on their device
|
||||
|
||||
**Compatibility Note: Mobile App v1.13+ is required for Mattermost Server v5.4+**
|
||||
**Combatibility Note: Mobile App v1.13+ is required for Mattermost Server v5.4+**
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue where the Android app did not allow establishing a network connection with any server that used a self-signed certificate that had the CA certificate user installed on the device.
|
||||
@@ -836,7 +333,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: October 18, 2018
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported
|
||||
|
||||
**Compatibility Note: Mobile App v1.13+ is required for Mattermost Server v5.4+**
|
||||
**Combatibility Note: Mobile App v1.13+ is required for Mattermost Server v5.4+**
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an issue preventing some users from authenticating using OKTA
|
||||
@@ -845,7 +342,7 @@ Mattermost Mobile App v1.26.0 contains low to medium level security fixes. [Upgr
|
||||
- Release Date: October 16, 2018
|
||||
- Server Versions Supported: Server v4.10+ is required, Self-Signed SSL Certificates are not supported
|
||||
|
||||
**Compatibility Note: Mobile App v1.13+ is required for Mattermost Server v5.4+**
|
||||
**Combatibility Note: Mobile App v1.13+ is required for Mattermost Server v5.4+**
|
||||
|
||||
### Highlights
|
||||
|
||||
|
||||
76
Gemfile.lock
76
Gemfile.lock
@@ -1,76 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.2)
|
||||
activesupport (4.2.11.1)
|
||||
i18n (~> 0.7)
|
||||
minitest (~> 5.1)
|
||||
thread_safe (~> 0.3, >= 0.3.4)
|
||||
tzinfo (~> 1.1)
|
||||
atomos (0.1.3)
|
||||
claide (1.0.3)
|
||||
cocoapods (1.7.5)
|
||||
activesupport (>= 4.0.2, < 5)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
cocoapods-core (= 1.7.5)
|
||||
cocoapods-deintegrate (>= 1.0.3, < 2.0)
|
||||
cocoapods-downloader (>= 1.2.2, < 2.0)
|
||||
cocoapods-plugins (>= 1.0.0, < 2.0)
|
||||
cocoapods-search (>= 1.0.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)
|
||||
fourflusher (>= 2.3.0, < 3.0)
|
||||
gh_inspector (~> 1.0)
|
||||
molinillo (~> 0.6.6)
|
||||
nap (~> 1.0)
|
||||
ruby-macho (~> 1.4)
|
||||
xcodeproj (>= 1.10.0, < 2.0)
|
||||
cocoapods-core (1.7.5)
|
||||
activesupport (>= 4.0.2, < 6)
|
||||
fuzzy_match (~> 2.0.4)
|
||||
nap (~> 1.0)
|
||||
cocoapods-deintegrate (1.0.4)
|
||||
cocoapods-downloader (1.3.0)
|
||||
cocoapods-plugins (1.0.0)
|
||||
nap
|
||||
cocoapods-search (1.0.0)
|
||||
cocoapods-stats (1.1.0)
|
||||
cocoapods-trunk (1.4.1)
|
||||
nap (>= 0.8, < 2.0)
|
||||
netrc (~> 0.11)
|
||||
cocoapods-try (1.1.0)
|
||||
colored2 (3.1.2)
|
||||
concurrent-ruby (1.1.5)
|
||||
escape (0.0.4)
|
||||
fourflusher (2.3.1)
|
||||
fuzzy_match (2.0.4)
|
||||
gh_inspector (1.1.3)
|
||||
i18n (0.9.5)
|
||||
concurrent-ruby (~> 1.0)
|
||||
minitest (5.14.0)
|
||||
molinillo (0.6.6)
|
||||
nanaimo (0.2.6)
|
||||
nap (1.1.0)
|
||||
netrc (0.11.0)
|
||||
ruby-macho (1.4.0)
|
||||
thread_safe (0.3.6)
|
||||
tzinfo (1.2.6)
|
||||
thread_safe (~> 0.1)
|
||||
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.2.6)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
cocoapods (= 1.7.5)
|
||||
|
||||
BUNDLED WITH
|
||||
2.0.2
|
||||
14
Jenkinsfile
vendored
Normal file
14
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
stages {
|
||||
stage('Test') {
|
||||
steps {
|
||||
echo 'assets/base/config.json'
|
||||
sh 'cat assets/base/config.json'
|
||||
sh 'touch .podinstall'
|
||||
sh 'make test || exit 1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Makefile
41
Makefile
@@ -7,6 +7,7 @@
|
||||
.PHONY: build-pr can-build-pr prepare-pr
|
||||
.PHONY: test help
|
||||
|
||||
POD := $(shell which pod 2> /dev/null)
|
||||
OS := $(shell sh -c 'uname -s 2>/dev/null')
|
||||
BASE_ASSETS = $(shell find assets/base -type d) $(shell find assets/base -type f -name '*')
|
||||
OVERRIDE_ASSETS = $(shell find assets/override -type d 2> /dev/null) $(shell find assets/override -type f -name '*' 2> /dev/null)
|
||||
@@ -32,11 +33,13 @@ npm-ci: package.json
|
||||
|
||||
.podinstall:
|
||||
ifeq ($(OS), Darwin)
|
||||
@echo "Required version of Cocoapods is not installed"
|
||||
@echo Installing gems;
|
||||
@bundle install
|
||||
ifdef POD
|
||||
@echo Getting Cocoapods dependencies;
|
||||
@cd ios && bundle exec pod install;
|
||||
@cd ios && pod install;
|
||||
else
|
||||
@echo "Cocoapods is not installed https://cocoapods.org/"
|
||||
@exit 1
|
||||
endif
|
||||
endif
|
||||
@touch $@
|
||||
|
||||
@@ -61,18 +64,24 @@ check-style: node_modules ## Runs eslint
|
||||
clean: ## Cleans dependencies, previous builds and temp files
|
||||
@echo Cleaning started
|
||||
|
||||
@rm -f .podinstall
|
||||
@rm -rf ios/Pods
|
||||
@rm -rf node_modules
|
||||
@rm -f .podinstall
|
||||
@rm -rf dist
|
||||
@rm -rf ios/build
|
||||
@rm -rf ios/Pods
|
||||
@rm -rf android/app/build
|
||||
|
||||
@echo Cleanup finished
|
||||
|
||||
post-install:
|
||||
@./node_modules/.bin/patch-package
|
||||
@./node_modules/.bin/jetify
|
||||
@# Need to copy custom RNDocumentPicker.m that implements direct access to the document picker in iOS
|
||||
@cp ./native_modules/RNDocumentPicker.m node_modules/react-native-document-picker/ios/RNDocumentPicker/RNDocumentPicker.m
|
||||
|
||||
@# Need to copy custom RNCookieManagerIOS.m that fixes a crash when cookies does not have expiration date set
|
||||
@cp ./native_modules/RNCookieManagerIOS.m node_modules/react-native-cookies/ios/RNCookieManagerIOS/RNCookieManagerIOS.m
|
||||
|
||||
@# Need to copy custom RNCNetInfo.m that checks for internet connectivity instead of reaching a host by default
|
||||
@cp ./native_modules/RNCNetInfo.m node_modules/@react-native-community/netinfo/ios/RNCNetInfo.m
|
||||
|
||||
@rm -f node_modules/intl/.babelrc
|
||||
@# Hack to get react-intl and its dependencies to work with react-native
|
||||
@@ -81,6 +90,10 @@ post-install:
|
||||
@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
|
||||
@if [ $(shell grep "const Platform" node_modules/react-native/Libraries/Lists/VirtualizedList.js | grep -civ grep) -eq 0 ]; then \
|
||||
sed $ -i'' -e "s|const ReactNative = require('ReactNative');|const ReactNative = require('ReactNative');`echo $\\\\\\r;`const Platform = require('Platform');|g" node_modules/react-native/Libraries/Lists/VirtualizedList.js; \
|
||||
fi
|
||||
@sed -i'' -e 's|transform: \[{scaleY: -1}\],|...Platform.select({android: {transform: \[{perspective: 1}, {scaleY: -1}\]}, ios: {transform: \[{scaleY: -1}\]}}),|g' node_modules/react-native/Libraries/Lists/VirtualizedList.js
|
||||
|
||||
start: | pre-run ## Starts the React Native packager server
|
||||
$(call start_packager)
|
||||
@@ -182,7 +195,14 @@ build-android: | stop pre-build check-style i18n-extract-ci prepare-android-buil
|
||||
|
||||
unsigned-ios: stop pre-build check-style ## Build an unsigned version of the iOS app
|
||||
$(call start_packager)
|
||||
@echo "Building unsigned iOS app"
|
||||
@cd fastlane && NODE_ENV=production bundle exec fastlane ios unsigned
|
||||
@mkdir -p build-ios
|
||||
@cd ios/ && xcodebuild -workspace Mattermost.xcworkspace/ -scheme Mattermost -sdk iphoneos -configuration Release -parallelizeTargets -resultBundlePath ../build-ios/result -derivedDataPath ../build-ios/ CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
|
||||
@cd build-ios/ && mkdir -p Payload && cp -R Build/Products/Release-iphoneos/Mattermost.app Payload/ && zip -r Mattermost-unsigned.ipa Payload/
|
||||
@mv build-ios/Mattermost-unsigned.ipa .
|
||||
@cd fastlane && bundle exec fastlane upload_file_to_s3 file:Mattermost-unsigned.ipa os_type:iOS
|
||||
@rm -rf build-ios/
|
||||
$(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
|
||||
@@ -198,7 +218,12 @@ ios-sim-x86_64: stop pre-build check-style ## Build an unsigned x86_64 version o
|
||||
$(call stop_packager)
|
||||
|
||||
unsigned-android: stop pre-build check-style prepare-android-build ## Build an unsigned version of the Android app
|
||||
$(call start_packager)
|
||||
@echo "Building unsigned Android app"
|
||||
@cd fastlane && NODE_ENV=production bundle exec fastlane android unsigned
|
||||
@mv android/app/build/outputs/apk/unsigned/app-unsigned-unsigned.apk ./Mattermost-unsigned.apk
|
||||
@cd fastlane && bundle exec fastlane upload_file_to_s3 file:Mattermost-unsigned.apk os_type:Android
|
||||
$(call stop_packager)
|
||||
|
||||
test: | pre-run check-style ## Runs tests
|
||||
@npm test
|
||||
|
||||
1292
NOTICE.txt
1292
NOTICE.txt
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,22 @@
|
||||
<!-- Thank you for contributing a pull request! Here are a few tips to help you:
|
||||
Please make sure you've read the [pull request](https://developers.mattermost.com/contribute/getting-started/contribution-checklist/) section of our [code contribution guidelines](https://developers.mattermost.com/contribute/getting-started/).
|
||||
|
||||
1. If this is your first contribution, make sure you've read the Contribution Checklist https://developers.mattermost.com/contribute/getting-started/contribution-checklist/
|
||||
2. Read our blog post about "Submitting Great PRs" https://developers.mattermost.com/blog/2019-01-24-submitting-great-prs
|
||||
3. Take a look at other repository specific documentation at https://developers.mattermost.com/contribute
|
||||
-->
|
||||
When filling in a section please remove the help text and the above text.
|
||||
|
||||
#### Summary
|
||||
<!--
|
||||
A brief description of what this pull request does.
|
||||
-->
|
||||
[A brief description of what this pull request does.]
|
||||
|
||||
#### Ticket Link
|
||||
<!--
|
||||
If this pull request addresses a Help Wanted ticket, please link the relevant GitHub issue, e.g.
|
||||
|
||||
Fixes https://github.com/mattermost/mattermost-server/issues/XXXXX
|
||||
|
||||
Otherwise, link the JIRA ticket.
|
||||
-->
|
||||
[Please link the GitHub issue or Jira ticket this PR addresses.]
|
||||
|
||||
#### Checklist
|
||||
<!--
|
||||
Place an '[x]' (no spaces) in all applicable fields. Please remove unrelated fields.
|
||||
-->
|
||||
[Place an '[x]' (no spaces) in all applicable fields. Please remove unrelated fields.]
|
||||
- [ ] Added or updated unit tests (required for all new features)
|
||||
- [ ] All new/modified APIs include changes to [mattermost-redux](https://github.com/mattermost/mattermost-redux) (please link)
|
||||
- [ ] Has UI changes
|
||||
- [ ] Includes text changes and localization file updates
|
||||
|
||||
#### Device Information
|
||||
This PR was tested on: <!-- Device name(s), OS version(s) -->
|
||||
This PR was tested on: [Device name(s), OS version(s)]
|
||||
|
||||
#### Screenshots
|
||||
<!--
|
||||
If the PR includes UI changes, include screenshots/GIFs (for both iOS and Android if possible).
|
||||
-->
|
||||
[If the PR includes UI changes, include screenshots (for both iOS and Android if possible).]
|
||||
|
||||
10
README.md
10
README.md
@@ -1,7 +1,7 @@
|
||||
# Mattermost Mobile
|
||||
|
||||
- **Minimum Server versions:** Current ESR version (5.19)
|
||||
- **Supported iOS versions:** 11+
|
||||
- **Supported Server versions:** 4.10+
|
||||
- **Supported iOS versions:** 10.3+
|
||||
- **Supported Android versions:** 7.0+
|
||||
|
||||
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 14 languages. Learn more at [https://about.mattermost.com](https://about.mattermost.com).
|
||||
@@ -20,7 +20,7 @@ To help with testing app updates before they're released, you can:
|
||||
|
||||
1. Sign up to be a beta tester
|
||||
- [Android](https://play.google.com/apps/testing/com.mattermost.rnbeta)
|
||||
- [iOS](https://testflight.apple.com/join/Q7Rx7K9P) - Open this link from your iOS device
|
||||
- [iOS](https://testflight.apple.com/join/Q7Rx7K9P)
|
||||
2. Install the `Mattermost Beta` app. New updates in the Beta app are released periodically. You will receive a notification when the new updates are available.
|
||||
3. File any bugs you find by filing a [GitHub issue](https://github.com/mattermost/mattermost-mobile/issues) with:
|
||||
- Device information
|
||||
@@ -29,10 +29,6 @@ To help with testing app updates before they're released, you can:
|
||||
- Expected behavior
|
||||
4. (Optional) [Sign up for our team site](https://pre-release.mattermost.com/signup_user_complete/?id=f1924a8db44ff3bb41c96424cdc20676)
|
||||
- Join the [Native Mobile Apps channel](https://pre-release.mattermost.com/core/channels/native-mobile-apps) to see what's new and discuss feedback with other contributors and the core team
|
||||
|
||||
You can leave the Beta testing program at any time:
|
||||
- On Android, [click this link](https://play.google.com/apps/testing/com.mattermost.rnbeta) while logged in with your Google Play email address used to opt-in for the Beta program, then click **Leave the program**.
|
||||
- On iOS, access the `Mattermost Beta` app page in TestFlight and click **Stop Testing**.
|
||||
|
||||
### Contribute Code
|
||||
|
||||
|
||||
25
SECURITY.md
25
SECURITY.md
@@ -1,25 +0,0 @@
|
||||
Security
|
||||
========
|
||||
|
||||
Safety and data security is of the utmost priority for the Mattermost community. If you are a security researcher and have discovered a security vulnerability in our codebase, we would appreciate your help in disclosing it to us in a responsible manner.
|
||||
|
||||
Reporting security issues
|
||||
-------------------------
|
||||
|
||||
**Please do not use GitHub issues for security-sensitive communication.**
|
||||
|
||||
Security issues in the community test server, any of the open source codebases maintained by Mattermost, or any of our commercial offerings should be reported via email to [responsibledisclosure@mattermost.com](mailto:responsibledisclosure@mattermost.com). Mattermost is committed to working together with researchers and keeping them updated throughout the patching process. Researchers who responsibly report valid security issues will be publicly credited for their efforts (if they so choose).
|
||||
|
||||
For a more detailed description of the disclosure process and a list of researchers who have previously contributed to the disclosure program, see [Report a Security Vulnerability](https://mattermost.com/security-vulnerability-report/) on the Mattermost website.
|
||||
|
||||
Security updates
|
||||
----------------
|
||||
|
||||
Mattermost has a mandatory upgrade policy, and updates are only provided for the latest release. Critical updates are delivered as dot releases. Details on security updates are announced 30 days after the availability of the update.
|
||||
|
||||
For more details about the security content of past releases, see the [Security Updates](https://mattermost.com/security-updates/) page on the Mattermost website. For timely notifications about new security updates, subscribe to the [Security Bulletins Mailing List](https://about.mattermost.com/security-bulletin).
|
||||
|
||||
Contributing to this policy
|
||||
---------------------------
|
||||
|
||||
If you have feedback or suggestions on improving this policy document, please [create an issue](https://github.com/mattermost/mattermost-mobile/issues/new).
|
||||
@@ -15,9 +15,7 @@ import com.android.build.OutputFile
|
||||
* // the name of the generated asset file containing your JS bundle
|
||||
* bundleAssetName: "index.android.bundle",
|
||||
*
|
||||
* // the entry file for bundle generation. If none specified and
|
||||
* // "index.android.js" exists, it will be used. Otherwise "index.js" is
|
||||
* // default. Can be overridden with ENTRY_FILE environment variable.
|
||||
* // the entry file for bundle generation
|
||||
* entryFile: "index.android.js",
|
||||
*
|
||||
* // whether to bundle JS and assets in debug mode
|
||||
@@ -76,9 +74,8 @@ import com.android.build.OutputFile
|
||||
|
||||
project.ext.react = [
|
||||
entryFile: "index.js",
|
||||
bundleConfig: "metro.config.js",
|
||||
bundleCommand: "ram-bundle",
|
||||
enableHermes: false,
|
||||
bundleConfig: "metro.config.js"
|
||||
]
|
||||
|
||||
apply from: "../../node_modules/react-native/react.gradle"
|
||||
@@ -90,7 +87,7 @@ if (System.getenv("SENTRY_ENABLED") == "true") {
|
||||
flavorAware: false
|
||||
]
|
||||
|
||||
apply from: "../../node_modules/@sentry/react-native/sentry.gradle"
|
||||
apply from: "../../node_modules/react-native-sentry/sentry.gradle"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,18 +105,6 @@ def enableSeparateBuildPerCPUArchitecture = false
|
||||
*/
|
||||
def enableProguardInReleaseBuilds = false
|
||||
|
||||
// Add v8-android - prebuilt libv8android.so into APK
|
||||
def jscFlavor = 'org.chromium:v8-android:+'
|
||||
|
||||
/**
|
||||
* Whether to enable the Hermes VM.
|
||||
*
|
||||
* This should be set on project.ext.react and mirrored here. If it is not set
|
||||
* on project.ext.react, JavaScript will not be compiled to Hermes Bytecode
|
||||
* and the benefits of using Hermes will therefore be sharply reduced.
|
||||
*/
|
||||
def enableHermes = project.ext.react.get("enableHermes", false);
|
||||
|
||||
android {
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
|
||||
@@ -128,13 +113,18 @@ android {
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
pickFirst '**/libjsc.so'
|
||||
pickFirst '**/libc++_shared.so'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.mattermost.rnbeta"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
|
||||
versionCode 307
|
||||
versionName "1.32.2"
|
||||
missingDimensionStrategy "RNN.reactNativeVersion", "reactNative57_5"
|
||||
versionCode 221
|
||||
versionName "1.22.0"
|
||||
multiDexEnabled = true
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a','arm64-v8a','x86','x86_64'
|
||||
@@ -172,7 +162,7 @@ android {
|
||||
unsigned.initWith(buildTypes.release)
|
||||
unsigned {
|
||||
signingConfig null
|
||||
matchingFallbacks = ['release']
|
||||
matchingFallbacks = ['debug', 'release']
|
||||
}
|
||||
}
|
||||
// applicationVariants are e.g. debug, release
|
||||
@@ -193,15 +183,6 @@ android {
|
||||
sourceCompatibility 1.8
|
||||
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 {
|
||||
@@ -230,45 +211,44 @@ configurations.all {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Make sure to put android-jsc at the top
|
||||
implementation "org.webkit:android-jsc-intl:r241213"
|
||||
|
||||
implementation fileTree(dir: "libs", include: ["*.jar"])
|
||||
|
||||
//noinspection GradleDynamicVersio
|
||||
implementation "com.android.support:appcompat-v7:${rootProject.ext.supportLibVersion}"
|
||||
implementation 'com.android.support:design:28.0.0'
|
||||
implementation 'com.android.support:percent:28.0.0'
|
||||
implementation "com.google.firebase:firebase-messaging:17.3.0"
|
||||
implementation "com.facebook.react:react-native:+" // From node_modules
|
||||
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
|
||||
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.facebook.fbjni'
|
||||
}
|
||||
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.facebook.flipper'
|
||||
}
|
||||
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.facebook.flipper'
|
||||
}
|
||||
|
||||
|
||||
if (enableHermes) {
|
||||
def hermesPath = "../../node_modules/hermes-engine/android/";
|
||||
debugImplementation files(hermesPath + "hermes-debug.aar")
|
||||
releaseImplementation files(hermesPath + "hermes-release.aar")
|
||||
unsignedImplementation files(hermesPath + "hermes-release.aar")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.0.0'
|
||||
implementation 'com.google.android.material:material:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
|
||||
implementation project(':react-native-document-picker')
|
||||
implementation project(':react-native-keychain')
|
||||
implementation project(':react-native-doc-viewer')
|
||||
implementation project(':react-native-video')
|
||||
implementation project(':react-native-navigation')
|
||||
implementation project(':react-native-image-picker')
|
||||
implementation project(':react-native-device-info')
|
||||
implementation project(':reactnativenotifications')
|
||||
implementation 'com.google.firebase:firebase-messaging:17.3.4'
|
||||
implementation project(':react-native-cookies')
|
||||
implementation project(':react-native-linear-gradient')
|
||||
implementation project(':react-native-vector-icons')
|
||||
implementation project(':react-native-svg')
|
||||
implementation project(':react-native-local-auth')
|
||||
implementation project(':jail-monkey')
|
||||
implementation project(':react-native-youtube')
|
||||
implementation project(':react-native-sentry')
|
||||
implementation project(':react-native-exception-handler')
|
||||
implementation project(':rn-fetch-blob')
|
||||
implementation project(':react-native-webview')
|
||||
implementation project(':react-native-gesture-handler')
|
||||
implementation project(':@react-native-community_async-storage')
|
||||
implementation project(':@react-native-community_netinfo')
|
||||
|
||||
// For animated GIF support
|
||||
implementation 'com.facebook.fresco:fresco:2.0.0'
|
||||
implementation 'com.facebook.fresco:animated-gif:2.0.0'
|
||||
implementation 'com.facebook.fresco:fresco:1.10.0'
|
||||
implementation 'com.facebook.fresco:animated-gif:1.10.0'
|
||||
// For WebP support, including animated WebP
|
||||
implementation 'com.facebook.fresco:animated-webp:2.0.0'
|
||||
implementation 'com.facebook.fresco:webpsupport:2.0.0'
|
||||
implementation 'com.facebook.fresco:animated-webp:1.10.0'
|
||||
implementation 'com.facebook.fresco:webpsupport:1.10.0'
|
||||
}
|
||||
|
||||
// Run this once to be able to run the application with BUCK
|
||||
@@ -279,4 +259,3 @@ task copyDownloadableDepsToLibs(type: Copy) {
|
||||
}
|
||||
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
|
||||
|
||||
7
android/app/proguard-rules.pro
vendored
7
android/app/proguard-rules.pro
vendored
@@ -8,3 +8,10 @@
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# Add any project specific keep options here:
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
|
||||
* directory of this source tree.
|
||||
*/
|
||||
package com.rn;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.flipper.android.AndroidFlipperClient;
|
||||
import com.facebook.flipper.android.utils.FlipperUtils;
|
||||
import com.facebook.flipper.core.FlipperClient;
|
||||
import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
|
||||
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
|
||||
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
|
||||
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
|
||||
import com.facebook.react.ReactInstanceManager;
|
||||
import com.facebook.react.bridge.ReactContext;
|
||||
import com.facebook.react.modules.network.NetworkingModule;
|
||||
import okhttp3.OkHttpClient;
|
||||
public class ReactNativeFlipper {
|
||||
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
|
||||
if (FlipperUtils.shouldEnableFlipper(context)) {
|
||||
final FlipperClient client = AndroidFlipperClient.getInstance(context);
|
||||
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
|
||||
client.addPlugin(new ReactFlipperPlugin());
|
||||
client.addPlugin(new DatabasesFlipperPlugin(context));
|
||||
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
|
||||
client.addPlugin(CrashReporterPlugin.getInstance());
|
||||
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
|
||||
NetworkingModule.setCustomClientBuilder(
|
||||
new NetworkingModule.CustomClientBuilder() {
|
||||
@Override
|
||||
public void apply(OkHttpClient.Builder builder) {
|
||||
builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
|
||||
}
|
||||
});
|
||||
client.addPlugin(networkFlipperPlugin);
|
||||
client.start();
|
||||
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
|
||||
// Hence we run if after all native modules have been initialized
|
||||
ReactContext reactContext = reactInstanceManager.getCurrentReactContext();
|
||||
if (reactContext == null) {
|
||||
reactInstanceManager.addReactInstanceEventListener(
|
||||
new ReactInstanceManager.ReactInstanceEventListener() {
|
||||
@Override
|
||||
public void onReactContextInitialized(ReactContext reactContext) {
|
||||
reactInstanceManager.removeReactInstanceEventListener(this);
|
||||
reactContext.runOnNativeModulesQueueThread(
|
||||
new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
client.addPlugin(new FrescoFlipperPlugin());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
client.addPlugin(new FrescoFlipperPlugin());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission-sdk-23 android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
@@ -28,20 +27,13 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/app_name"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTask">
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<action android:name="android.intent.action.DOWNLOAD_COMPLETE"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="mattermost" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
|
||||
<service android:name=".NotificationDismissService"
|
||||
@@ -59,9 +51,7 @@
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
|
||||
android:label="@string/app_name"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/AppTheme"
|
||||
android:taskAffinity="com.mattermost.share"
|
||||
android:launchMode="singleInstance">
|
||||
android:theme="@style/AppTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
|
||||
Binary file not shown.
@@ -20,27 +20,23 @@ import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings.System;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.util.Log;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.wix.reactnativenotifications.core.notification.PushNotification;
|
||||
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
|
||||
import com.wix.reactnativenotifications.core.AppLaunchHelper;
|
||||
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
|
||||
import com.wix.reactnativenotifications.core.JsIOHelper;
|
||||
import com.wix.reactnativenotifications.helpers.ApplicationBadgeHelper;
|
||||
|
||||
import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME;
|
||||
|
||||
import com.mattermost.react_native_interface.ResolvePromise;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
|
||||
public class CustomPushNotification extends PushNotification {
|
||||
public static final int MESSAGE_NOTIFICATION_ID = 435345;
|
||||
public static final String GROUP_KEY_MESSAGES = "mm_group_key_messages";
|
||||
@@ -48,15 +44,8 @@ public class CustomPushNotification extends PushNotification {
|
||||
public static final String KEY_TEXT_REPLY = "CAN_REPLY";
|
||||
public static final String NOTIFICATION_REPLIED_EVENT_NAME = "notificationReplied";
|
||||
|
||||
private static final String PUSH_TYPE_MESSAGE = "message";
|
||||
private static final String PUSH_TYPE_CLEAR = "clear";
|
||||
private static final String PUSH_TYPE_UPDATE_BADGE = "update_badge";
|
||||
|
||||
private NotificationChannel mHighImportanceChannel;
|
||||
private NotificationChannel mMinImportanceChannel;
|
||||
|
||||
private static Map<String, Integer> channelIdToNotificationCount = new HashMap<String, Integer>();
|
||||
private static Map<String, List<Bundle>> channelIdToNotification = new HashMap<String, List<Bundle>>();
|
||||
private static LinkedHashMap<String,Integer> channelIdToNotificationCount = new LinkedHashMap<String,Integer>();
|
||||
private static LinkedHashMap<String,List<Bundle>> channelIdToNotification = new LinkedHashMap<String,List<Bundle>>();
|
||||
private static AppLifecycleFacade lifecycleFacade;
|
||||
private static Context context;
|
||||
private static int badgeCount = 0;
|
||||
@@ -64,14 +53,15 @@ public class CustomPushNotification extends PushNotification {
|
||||
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
|
||||
super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper);
|
||||
this.context = context;
|
||||
createNotificationChannels();
|
||||
}
|
||||
|
||||
public static void clearNotification(Context mContext, int notificationId, String channelId) {
|
||||
if (notificationId != -1) {
|
||||
Integer count = channelIdToNotificationCount.get(channelId);
|
||||
if (count == null) {
|
||||
count = -1;
|
||||
Object objCount = channelIdToNotificationCount.get(channelId);
|
||||
Integer count = -1;
|
||||
|
||||
if (objCount != null) {
|
||||
count = (Integer)objCount;
|
||||
}
|
||||
|
||||
channelIdToNotificationCount.remove(channelId);
|
||||
@@ -84,6 +74,7 @@ public class CustomPushNotification extends PushNotification {
|
||||
if (count != -1) {
|
||||
int total = CustomPushNotification.badgeCount - count;
|
||||
int badgeCount = total < 0 ? 0 : total;
|
||||
ApplicationBadgeHelper.instance.setApplicationIconBadgeNumber(mContext.getApplicationContext(), badgeCount);
|
||||
CustomPushNotification.badgeCount = badgeCount;
|
||||
}
|
||||
}
|
||||
@@ -96,62 +87,41 @@ public class CustomPushNotification extends PushNotification {
|
||||
if (mContext != null) {
|
||||
final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationManager.cancelAll();
|
||||
ApplicationBadgeHelper.instance.setApplicationIconBadgeNumber(mContext.getApplicationContext(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceived() throws InvalidNotificationException {
|
||||
final Bundle initialData = mNotificationProps.asBundle();
|
||||
final String type = initialData.getString("type");
|
||||
final String ackId = initialData.getString("ack_id");
|
||||
final String postId = initialData.getString("post_id");
|
||||
final String channelId = initialData.getString("channel_id");
|
||||
final boolean isIdLoaded = initialData.getString("id_loaded") != null ? initialData.getString("id_loaded").equals("true") : false;
|
||||
Bundle data = mNotificationProps.asBundle();
|
||||
final String channelId = data.getString("channel_id");
|
||||
final String type = data.getString("type");
|
||||
final String ackId = data.getString("ack_id");
|
||||
int notificationId = MESSAGE_NOTIFICATION_ID;
|
||||
|
||||
if (ackId != null) {
|
||||
notificationReceiptDelivery(ackId, postId, type, isIdLoaded, new ResolvePromise() {
|
||||
@Override
|
||||
public void resolve(@Nullable Object value) {
|
||||
if (isIdLoaded) {
|
||||
Bundle response = (Bundle) value;
|
||||
mNotificationProps = createProps(response);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reject(String code, String message) {
|
||||
Log.e("ReactNative", code + ": " + message);
|
||||
}
|
||||
});
|
||||
notificationReceiptDelivery(ackId, type);
|
||||
}
|
||||
|
||||
// notificationReceiptDelivery can override mNotificationProps
|
||||
// so we fetch the bundle again
|
||||
final Bundle data = mNotificationProps.asBundle();
|
||||
|
||||
if (channelId != null) {
|
||||
notificationId = channelId.hashCode();
|
||||
|
||||
synchronized (channelIdToNotificationCount) {
|
||||
Integer count = channelIdToNotificationCount.get(channelId);
|
||||
if (count == null) {
|
||||
count = 0;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
|
||||
channelIdToNotificationCount.put(channelId, count);
|
||||
Object objCount = channelIdToNotificationCount.get(channelId);
|
||||
Integer count = 1;
|
||||
if (objCount != null) {
|
||||
count = (Integer)objCount + 1;
|
||||
}
|
||||
channelIdToNotificationCount.put(channelId, count);
|
||||
|
||||
synchronized (channelIdToNotification) {
|
||||
List<Bundle> list = channelIdToNotification.get(channelId);
|
||||
if (list == null) {
|
||||
list = Collections.synchronizedList(new ArrayList(0));
|
||||
}
|
||||
|
||||
if (PUSH_TYPE_MESSAGE.equals(type)) {
|
||||
String senderName = getSenderName(data);
|
||||
Object bundleArray = channelIdToNotification.get(channelId);
|
||||
List list = null;
|
||||
if (bundleArray == null) {
|
||||
list = Collections.synchronizedList(new ArrayList(0));
|
||||
} else {
|
||||
list = Collections.synchronizedList((List)bundleArray);
|
||||
}
|
||||
synchronized (list) {
|
||||
if (!"clear".equals(type)) {
|
||||
String senderName = getSenderName(data.getString("sender_name"), data.getString("channel_name"), data.getString("message"));
|
||||
data.putLong("time", new Date().getTime());
|
||||
data.putString("sender_name", senderName);
|
||||
data.putString("sender_id", data.getString("sender_id"));
|
||||
@@ -161,13 +131,10 @@ public class CustomPushNotification extends PushNotification {
|
||||
}
|
||||
}
|
||||
|
||||
switch(type) {
|
||||
case PUSH_TYPE_MESSAGE:
|
||||
super.postNotification(notificationId);
|
||||
break;
|
||||
case PUSH_TYPE_CLEAR:
|
||||
if ("clear".equals(type)) {
|
||||
cancelNotification(data, notificationId);
|
||||
break;
|
||||
} else {
|
||||
super.postNotification(notificationId);
|
||||
}
|
||||
|
||||
notifyReceivedToJS();
|
||||
@@ -182,141 +149,59 @@ public class CustomPushNotification extends PushNotification {
|
||||
digestNotification();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void postNotification(int id, Notification notification) {
|
||||
boolean force = false;
|
||||
Bundle bundle = notification.extras;
|
||||
if (bundle != null) {
|
||||
force = bundle.getBoolean("localTest");
|
||||
}
|
||||
|
||||
if (!mAppLifecycleFacade.isAppVisible() || force) {
|
||||
super.postNotification(id, notification);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Notification.Builder getNotificationBuilder(PendingIntent intent) {
|
||||
final Resources res = mContext.getResources();
|
||||
String packageName = mContext.getPackageName();
|
||||
NotificationPreferences notificationPreferences = NotificationPreferences.getInstance(mContext);
|
||||
|
||||
// First, get a builder initialized with defaults from the core class.
|
||||
final Notification.Builder notification = new Notification.Builder(mContext);
|
||||
|
||||
// If Android Oreo or above we need to register a channel
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
String CHANNEL_ID = "channel_01";
|
||||
String CHANNEL_NAME = "Mattermost notifications";
|
||||
|
||||
NotificationChannel channel = new NotificationChannel(CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH);
|
||||
channel.setShowBadge(true);
|
||||
|
||||
final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
notification.setChannelId(CHANNEL_ID);
|
||||
}
|
||||
|
||||
Bundle bundle = mNotificationProps.asBundle();
|
||||
|
||||
addNotificationExtras(notification, bundle);
|
||||
setNotificationIcons(notification, bundle);
|
||||
setNotificationMessagingStyle(notification, bundle);
|
||||
setNotificationChannel(notification, bundle);
|
||||
setNotificationBadgeIconType(notification);
|
||||
|
||||
NotificationPreferences notificationPreferences = NotificationPreferences.getInstance(mContext);
|
||||
setNotificationSound(notification, notificationPreferences);
|
||||
setNotificationVibrate(notification, notificationPreferences);
|
||||
setNotificationBlink(notification, notificationPreferences);
|
||||
|
||||
String version = bundle.getString("version");
|
||||
String channelId = bundle.getString("channel_id");
|
||||
int notificationId = channelId != null ? channelId.hashCode() : MESSAGE_NOTIFICATION_ID;
|
||||
setNotificationNumber(notification, channelId);
|
||||
setNotificationDeleteIntent(notification, notificationId);
|
||||
addNotificationReplyAction(notification, notificationId, bundle);
|
||||
|
||||
notification
|
||||
.setContentIntent(intent)
|
||||
.setVisibility(Notification.VISIBILITY_PRIVATE)
|
||||
.setPriority(Notification.PRIORITY_HIGH)
|
||||
.setAutoCancel(true);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
private void addNotificationExtras(Notification.Builder notification, Bundle bundle) {
|
||||
Bundle userInfoBundle = bundle.getBundle("userInfo");
|
||||
if (userInfoBundle == null) {
|
||||
userInfoBundle = new Bundle();
|
||||
}
|
||||
|
||||
String channelId = bundle.getString("channel_id");
|
||||
userInfoBundle.putString("channel_id", channelId);
|
||||
|
||||
notification.addExtras(userInfoBundle);
|
||||
}
|
||||
|
||||
private void setNotificationIcons(Notification.Builder notification, Bundle bundle) {
|
||||
String channelName = bundle.getString("channel_name");
|
||||
String senderName = bundle.getString("sender_name");
|
||||
String senderId = bundle.getString("sender_id");
|
||||
String postId = bundle.getString("post_id");
|
||||
String badge = bundle.getString("badge");
|
||||
String smallIcon = bundle.getString("smallIcon");
|
||||
String largeIcon = bundle.getString("largeIcon");
|
||||
int notificationId = channelId != null ? channelId.hashCode() : MESSAGE_NOTIFICATION_ID;
|
||||
|
||||
int smallIconResId = getSmallIconResourceId(smallIcon);
|
||||
notification.setSmallIcon(smallIconResId);
|
||||
|
||||
int largeIconResId = getLargeIconResourceId(largeIcon);
|
||||
final Resources res = mContext.getResources();
|
||||
Bitmap largeIconBitmap = BitmapFactory.decodeResource(res, largeIconResId);
|
||||
if (largeIconResId != 0 && (largeIconBitmap != null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)) {
|
||||
notification.setLargeIcon(largeIconBitmap);
|
||||
}
|
||||
}
|
||||
|
||||
private int getSmallIconResourceId(String iconName) {
|
||||
if (iconName == null) {
|
||||
iconName = "ic_notification";
|
||||
}
|
||||
|
||||
int resourceId = getIconResourceId(iconName);
|
||||
|
||||
if (resourceId == 0) {
|
||||
iconName = "ic_launcher";
|
||||
resourceId = getIconResourceId(iconName);
|
||||
|
||||
if (resourceId == 0) {
|
||||
resourceId = android.R.drawable.ic_dialog_info;
|
||||
}
|
||||
}
|
||||
|
||||
return resourceId;
|
||||
}
|
||||
|
||||
private int getLargeIconResourceId(String iconName) {
|
||||
if (iconName == null) {
|
||||
iconName = "ic_launcher";
|
||||
}
|
||||
|
||||
return getIconResourceId(iconName);
|
||||
}
|
||||
|
||||
private int getIconResourceId(String iconName) {
|
||||
final Resources res = mContext.getResources();
|
||||
String packageName = mContext.getPackageName();
|
||||
String defType = "mipmap";
|
||||
|
||||
return res.getIdentifier(iconName, defType, packageName);
|
||||
}
|
||||
|
||||
private void setNotificationNumber(Notification.Builder notification, String channelId) {
|
||||
Integer number = channelIdToNotificationCount.get(channelId);
|
||||
if (number == null) {
|
||||
number = 0;
|
||||
}
|
||||
notification.setNumber(number);
|
||||
}
|
||||
|
||||
private void setNotificationMessagingStyle(Notification.Builder notification, Bundle bundle) {
|
||||
Notification.MessagingStyle messagingStyle = getMessagingStyle(bundle);
|
||||
notification.setStyle(messagingStyle);
|
||||
}
|
||||
|
||||
private Notification.MessagingStyle getMessagingStyle(Bundle bundle) {
|
||||
Notification.MessagingStyle messagingStyle;
|
||||
|
||||
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("")
|
||||
.build();
|
||||
messagingStyle = new Notification.MessagingStyle(sender);
|
||||
}
|
||||
|
||||
String conversationTitle = getConversationTitle(bundle);
|
||||
setMessagingStyleConversationTitle(messagingStyle, conversationTitle, bundle);
|
||||
addMessagingStyleMessages(messagingStyle, conversationTitle, bundle);
|
||||
|
||||
return messagingStyle;
|
||||
}
|
||||
|
||||
private String getConversationTitle(Bundle bundle) {
|
||||
String title = null;
|
||||
|
||||
String version = bundle.getString("version");
|
||||
if (version != null && version.equals("v2")) {
|
||||
title = bundle.getString("channel_name");
|
||||
title = channelName;
|
||||
} else {
|
||||
title = bundle.getString("title");
|
||||
}
|
||||
@@ -326,103 +211,149 @@ public class CustomPushNotification extends PushNotification {
|
||||
title = mContext.getPackageManager().getApplicationLabel(appInfo).toString();
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
private void setMessagingStyleConversationTitle(Notification.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) {
|
||||
String channelName = getConversationTitle(bundle);
|
||||
String senderName = bundle.getString("sender_name");
|
||||
if (android.text.TextUtils.isEmpty(senderName)) {
|
||||
senderName = getSenderName(bundle);
|
||||
Bundle b = bundle.getBundle("userInfo");
|
||||
if (b == null) {
|
||||
b = new Bundle();
|
||||
}
|
||||
b.putString("channel_id", channelId);
|
||||
notification.addExtras(b);
|
||||
|
||||
if (conversationTitle != null && (!conversationTitle.startsWith("@") || channelName != senderName)) {
|
||||
messagingStyle.setConversationTitle(conversationTitle);
|
||||
}
|
||||
}
|
||||
int smallIconResId;
|
||||
int largeIconResId;
|
||||
|
||||
private void addMessagingStyleMessages(Notification.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) {
|
||||
List<Bundle> bundleList;
|
||||
|
||||
String channelId = bundle.getString("channel_id");
|
||||
List<Bundle> bundleArray = channelIdToNotification.get(channelId);
|
||||
if (bundleArray != null) {
|
||||
bundleList = new ArrayList<Bundle>(bundleArray);
|
||||
if (smallIcon != null) {
|
||||
smallIconResId = res.getIdentifier(smallIcon, "mipmap", packageName);
|
||||
} else {
|
||||
bundleList = new ArrayList<Bundle>();
|
||||
bundleList.add(bundle);
|
||||
smallIconResId = res.getIdentifier("ic_notification", "mipmap", packageName);
|
||||
}
|
||||
|
||||
int bundleCount = bundleList.size() - 1;
|
||||
for (int i = bundleCount; i >= 0; i--) {
|
||||
Bundle data = bundleList.get(i);
|
||||
if (smallIconResId == 0) {
|
||||
smallIconResId = res.getIdentifier("ic_launcher", "mipmap", packageName);
|
||||
|
||||
if (smallIconResId == 0) {
|
||||
smallIconResId = android.R.drawable.ic_dialog_info;
|
||||
}
|
||||
}
|
||||
|
||||
if (largeIcon != null) {
|
||||
largeIconResId = res.getIdentifier(largeIcon, "mipmap", packageName);
|
||||
} else {
|
||||
largeIconResId = res.getIdentifier("ic_launcher", "mipmap", packageName);
|
||||
}
|
||||
|
||||
if (badge != null) {
|
||||
int badgeCount = Integer.parseInt(badge);
|
||||
CustomPushNotification.badgeCount = badgeCount;
|
||||
notification.setNumber(badgeCount);
|
||||
ApplicationBadgeHelper.instance.setApplicationIconBadgeNumber(mContext.getApplicationContext(), CustomPushNotification.badgeCount);
|
||||
}
|
||||
|
||||
if (android.text.TextUtils.isEmpty(senderName)) {
|
||||
senderName = getSenderName(senderName, channelName, bundle.getString("message"));
|
||||
}
|
||||
|
||||
String personId = senderId;
|
||||
if (!android.text.TextUtils.isEmpty(channelName)) {
|
||||
personId = channelId;
|
||||
}
|
||||
|
||||
Notification.MessagingStyle messagingStyle;
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
messagingStyle = new Notification.MessagingStyle("");
|
||||
} else {
|
||||
Person sender = new Person.Builder()
|
||||
.setKey(senderId)
|
||||
.setName("")
|
||||
.build();
|
||||
messagingStyle = new Notification.MessagingStyle(sender);
|
||||
}
|
||||
|
||||
if (title != null && (!title.startsWith("@") || channelName != senderName)) {
|
||||
messagingStyle
|
||||
.setConversationTitle(title);
|
||||
}
|
||||
|
||||
List<Bundle> bundleArray = channelIdToNotification.get(channelId);
|
||||
List<Bundle> list;
|
||||
if (bundleArray != null) {
|
||||
list = new ArrayList<Bundle>(bundleArray);
|
||||
} else {
|
||||
list = new ArrayList<Bundle>();
|
||||
list.add(bundle);
|
||||
}
|
||||
|
||||
int listCount = list.size() - 1;
|
||||
for (int i = listCount; i >= 0; i--) {
|
||||
Bundle data = list.get(i);
|
||||
String message = data.getString("message");
|
||||
String senderId = data.getString("sender_id");
|
||||
if (senderId == null) {
|
||||
senderId = "sender_id";
|
||||
}
|
||||
Bundle userInfoBundle = data.getBundle("userInfo");
|
||||
String senderName = getSenderName(data);
|
||||
if (userInfoBundle != null) {
|
||||
boolean localPushNotificationTest = userInfoBundle.getBoolean("localTest");
|
||||
if (localPushNotificationTest) {
|
||||
senderName = "Test";
|
||||
}
|
||||
String previousPersonName = getSenderName(data.getString("sender_name"), channelName, message);
|
||||
String previousPersonId = data.getString("sender_id");
|
||||
|
||||
if (title == null || !android.text.TextUtils.isEmpty(previousPersonName)) {
|
||||
message = removeSenderFromMessage(previousPersonName, channelName, message);
|
||||
}
|
||||
|
||||
if (conversationTitle == null || !android.text.TextUtils.isEmpty(senderName.trim())) {
|
||||
message = removeSenderNameFromMessage(message, senderName);
|
||||
}
|
||||
|
||||
long timestamp = data.getLong("time");
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
messagingStyle.addMessage(message, timestamp, senderName);
|
||||
messagingStyle.addMessage(message, data.getLong("time"), previousPersonName);
|
||||
} else {
|
||||
Person sender = new Person.Builder()
|
||||
.setKey(senderId)
|
||||
.setName(senderName)
|
||||
.build();
|
||||
messagingStyle.addMessage(message, timestamp, sender);
|
||||
.setKey(previousPersonId)
|
||||
.setName(previousPersonName)
|
||||
.build();
|
||||
messagingStyle.addMessage(message, data.getLong("time"), sender);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setNotificationChannel(Notification.Builder notification, Bundle bundle) {
|
||||
// If Android Oreo or above we need to register a channel
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
notification
|
||||
.setContentIntent(intent)
|
||||
.setGroupSummary(true)
|
||||
.setStyle(messagingStyle)
|
||||
.setSmallIcon(smallIconResId)
|
||||
.setVisibility(Notification.VISIBILITY_PRIVATE)
|
||||
.setPriority(Notification.PRIORITY_HIGH)
|
||||
.setAutoCancel(true);
|
||||
|
||||
NotificationChannel notificationChannel = mHighImportanceChannel;
|
||||
|
||||
boolean localPushNotificationTest = false;
|
||||
Bundle userInfoBundle = bundle.getBundle("userInfo");
|
||||
if (userInfoBundle != null) {
|
||||
localPushNotificationTest = userInfoBundle.getBoolean("localTest");
|
||||
}
|
||||
|
||||
if (mAppLifecycleFacade.isAppVisible() && !localPushNotificationTest) {
|
||||
notificationChannel = mMinImportanceChannel;
|
||||
}
|
||||
|
||||
notification.setChannelId(notificationChannel.getId());
|
||||
}
|
||||
|
||||
private void setNotificationBadgeIconType(Notification.Builder notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notification.setBadgeIconType(Notification.BADGE_ICON_SMALL);
|
||||
}
|
||||
}
|
||||
|
||||
private void setNotificationGroup(Notification.Builder notification) {
|
||||
// Let's add a delete intent when the notification is dismissed
|
||||
Intent delIntent = new Intent(mContext, NotificationDismissService.class);
|
||||
delIntent.putExtra(NOTIFICATION_ID, notificationId);
|
||||
PendingIntent deleteIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, delIntent, mNotificationProps);
|
||||
notification.setDeleteIntent(deleteIntent);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
notification
|
||||
.setGroup(GROUP_KEY_MESSAGES)
|
||||
.setGroupSummary(true);
|
||||
notification.setGroup(GROUP_KEY_MESSAGES);
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && postId != null) {
|
||||
Intent replyIntent = new Intent(mContext, NotificationReplyBroadcastReceiver.class);
|
||||
replyIntent.setAction(KEY_TEXT_REPLY);
|
||||
replyIntent.putExtra(NOTIFICATION_ID, notificationId);
|
||||
replyIntent.putExtra("pushNotification", bundle);
|
||||
PendingIntent replyPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
|
||||
RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||
.setLabel("Reply")
|
||||
.build();
|
||||
|
||||
Notification.Action replyAction = new Notification.Action.Builder(
|
||||
R.drawable.ic_notif_action_reply, "Reply", replyPendingIntent)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build();
|
||||
|
||||
notification
|
||||
.setShowWhen(true)
|
||||
.addAction(replyAction);
|
||||
}
|
||||
|
||||
Bitmap largeIconBitmap = BitmapFactory.decodeResource(res, largeIconResId);
|
||||
if (largeIconResId != 0 && (largeIcon != null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)) {
|
||||
notification.setLargeIcon(largeIconBitmap);
|
||||
}
|
||||
}
|
||||
|
||||
private void setNotificationSound(Notification.Builder notification, NotificationPreferences notificationPreferences) {
|
||||
String soundUri = notificationPreferences.getNotificationSound();
|
||||
if (soundUri != null) {
|
||||
if (soundUri != "none") {
|
||||
@@ -432,120 +363,65 @@ public class CustomPushNotification extends PushNotification {
|
||||
Uri defaultUri = System.DEFAULT_NOTIFICATION_URI;
|
||||
notification.setSound(defaultUri, AudioManager.STREAM_NOTIFICATION);
|
||||
}
|
||||
}
|
||||
|
||||
private void setNotificationVibrate(Notification.Builder notification, NotificationPreferences notificationPreferences) {
|
||||
boolean vibrate = notificationPreferences.getShouldVibrate();
|
||||
if (vibrate) {
|
||||
// Use the system default for vibration
|
||||
// use the system default for vibration
|
||||
notification.setDefaults(Notification.DEFAULT_VIBRATE);
|
||||
}
|
||||
}
|
||||
|
||||
private void setNotificationBlink(Notification.Builder notification, NotificationPreferences notificationPreferences) {
|
||||
boolean blink = notificationPreferences.getShouldBlink();
|
||||
if (blink) {
|
||||
notification.setLights(Color.CYAN, 500, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private void setNotificationDeleteIntent(Notification.Builder notification, int notificationId) {
|
||||
// Let's add a delete intent when the notification is dismissed
|
||||
Intent delIntent = new Intent(mContext, NotificationDismissService.class);
|
||||
delIntent.putExtra(NOTIFICATION_ID, notificationId);
|
||||
PendingIntent deleteIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, delIntent, mNotificationProps);
|
||||
notification.setDeleteIntent(deleteIntent);
|
||||
}
|
||||
|
||||
private void addNotificationReplyAction(Notification.Builder notification, int notificationId, Bundle bundle) {
|
||||
String postId = bundle.getString("post_id");
|
||||
if (postId == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return;
|
||||
}
|
||||
|
||||
Intent replyIntent = new Intent(mContext, NotificationReplyBroadcastReceiver.class);
|
||||
replyIntent.setAction(KEY_TEXT_REPLY);
|
||||
replyIntent.putExtra(NOTIFICATION_ID, notificationId);
|
||||
replyIntent.putExtra("pushNotification", bundle);
|
||||
|
||||
PendingIntent replyPendingIntent = PendingIntent.getBroadcast(
|
||||
mContext,
|
||||
notificationId,
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
|
||||
RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||
.setLabel("Reply")
|
||||
.build();
|
||||
|
||||
int icon = R.drawable.ic_notif_action_reply;
|
||||
CharSequence title = "Reply";
|
||||
Notification.Action replyAction = new Notification.Action.Builder(icon, title, replyPendingIntent)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build();
|
||||
|
||||
notification
|
||||
.setShowWhen(true)
|
||||
.addAction(replyAction);
|
||||
return notification;
|
||||
}
|
||||
|
||||
private void notifyReceivedToJS() {
|
||||
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.getRunningReactContext());
|
||||
}
|
||||
|
||||
private void cancelNotification(Bundle data, int notificationId) {
|
||||
final String channelId = data.getString("channel_id");
|
||||
final String badge = data.getString("badge");
|
||||
|
||||
CustomPushNotification.badgeCount = Integer.parseInt(badge);
|
||||
CustomPushNotification.clearNotification(mContext.getApplicationContext(), notificationId, channelId);
|
||||
}
|
||||
|
||||
private String getSenderName(Bundle bundle) {
|
||||
String senderName = bundle.getString("sender_name");
|
||||
if (senderName != null) {
|
||||
return senderName;
|
||||
public static Integer getMessageCountInChannel(String channelId) {
|
||||
Object objCount = channelIdToNotificationCount.get(channelId);
|
||||
if (objCount != null) {
|
||||
return (Integer)objCount;
|
||||
}
|
||||
|
||||
String channelName = bundle.getString("channel_name");
|
||||
if (channelName != null && channelName.startsWith("@")) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private void cancelNotification(Bundle data, int notificationId) {
|
||||
final String channelId = data.getString("channel_id");
|
||||
final String numberString = data.getString("badge");
|
||||
|
||||
CustomPushNotification.badgeCount = Integer.parseInt(numberString);
|
||||
CustomPushNotification.clearNotification(mContext.getApplicationContext(), notificationId, channelId);
|
||||
|
||||
ApplicationBadgeHelper.instance.setApplicationIconBadgeNumber(mContext.getApplicationContext(), CustomPushNotification.badgeCount);
|
||||
}
|
||||
|
||||
private String getSenderName(String senderName, String channelName, String message) {
|
||||
if (senderName != null) {
|
||||
return senderName;
|
||||
} else if (channelName != null && channelName.startsWith("@")) {
|
||||
return channelName;
|
||||
}
|
||||
|
||||
String message = bundle.getString("message");
|
||||
if (message != null) {
|
||||
String name = message.split(":")[0];
|
||||
if (name != message) {
|
||||
return name;
|
||||
}
|
||||
String name = message.split(":")[0];
|
||||
if (name != message) {
|
||||
return name;
|
||||
}
|
||||
|
||||
return getConversationTitle(bundle);
|
||||
return " ";
|
||||
}
|
||||
|
||||
private String removeSenderNameFromMessage(String message, String senderName) {
|
||||
return message.replaceFirst(senderName, "").replaceFirst(": ", "").trim();
|
||||
private String removeSenderFromMessage(String senderName, String channelName, String message) {
|
||||
String sender = String.format("%s", getSenderName(senderName, channelName, message));
|
||||
return message.replaceFirst(sender, "").replaceFirst(": ", "").trim();
|
||||
}
|
||||
|
||||
private void notificationReceiptDelivery(String ackId, String postId, String type, boolean isIdLoaded, ResolvePromise promise) {
|
||||
ReceiptDelivery.send(context, ackId, postId, type, isIdLoaded, promise);
|
||||
}
|
||||
|
||||
private void createNotificationChannels() {
|
||||
// Notification channels are not supported in Android Nougat and below
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
|
||||
final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
mHighImportanceChannel = new NotificationChannel("channel_01", "High Importance", NotificationManager.IMPORTANCE_HIGH);
|
||||
mHighImportanceChannel.setShowBadge(true);
|
||||
notificationManager.createNotificationChannel(mHighImportanceChannel);
|
||||
|
||||
mMinImportanceChannel = new NotificationChannel("channel_02", "Min Importance", NotificationManager.IMPORTANCE_MIN);
|
||||
mMinImportanceChannel.setShowBadge(true);
|
||||
notificationManager.createNotificationChannel(mMinImportanceChannel);
|
||||
private void notificationReceiptDelivery(String ackId, String type) {
|
||||
ReceiptDelivery.send(context, ackId, type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.wix.reactnativenotifications.core.AppLaunchHelper;
|
||||
import com.wix.reactnativenotifications.core.notificationdrawer.PushNotificationsDrawer;
|
||||
import com.wix.reactnativenotifications.core.notificationdrawer.IPushNotificationsDrawer;
|
||||
import com.wix.reactnativenotifications.core.notificationdrawer.INotificationsDrawerApplication;
|
||||
|
||||
import com.wix.reactnativenotifications.helpers.PushNotificationHelper;
|
||||
import static com.wix.reactnativenotifications.Defs.LOGTAG;
|
||||
|
||||
public class CustomPushNotificationDrawer extends PushNotificationsDrawer {
|
||||
|
||||
@@ -1,50 +1,28 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.os.Bundle;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.view.KeyEvent;
|
||||
import android.content.res.Configuration;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import com.reactnativenavigation.NavigationActivity;
|
||||
import com.github.emilioicai.hwkeyboardevent.HWKeyboardEventModule;
|
||||
|
||||
public class MainActivity extends NavigationActivity {
|
||||
private boolean HWKeyboardConnected = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.launch_screen);
|
||||
setHWKeyboardConnected();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
|
||||
if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO) {
|
||||
HWKeyboardConnected = true;
|
||||
} else if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_YES) {
|
||||
HWKeyboardConnected = false;
|
||||
/**
|
||||
* Reference: https://stackoverflow.com/questions/7944338/resume-last-activity-when-launcher-icon-is-clicked
|
||||
* 1. Open app from launcher/appDrawer
|
||||
* 2. Go home
|
||||
* 3. Send notification and open
|
||||
* 4. It creates a new Activity and Destroys the old
|
||||
* 5. Causing an unnecessary app restart
|
||||
* 6. This solution short-circuits the restart
|
||||
*/
|
||||
if (!isTaskRoot()) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
https://mattermost.atlassian.net/browse/MM-10601
|
||||
Required by react-native-hw-keyboard-event
|
||||
(https://github.com/emilioicai/react-native-hw-keyboard-event)
|
||||
*/
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
if (HWKeyboardConnected && event.getKeyCode() == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
|
||||
String keyPressed = event.isShiftPressed() ? "shift-enter" : "enter";
|
||||
HWKeyboardEventModule.getInstance().keyPressed(keyPressed);
|
||||
return true;
|
||||
}
|
||||
return super.dispatchKeyEvent(event);
|
||||
};
|
||||
|
||||
private void setHWKeyboardConnected() {
|
||||
HWKeyboardConnected = getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import android.app.Activity;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.content.Context;
|
||||
import android.content.RestrictionsManager;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.io.File;
|
||||
import java.util.HashMap;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.mattermost.share.RealPathUtil;
|
||||
|
||||
import com.mattermost.share.ShareModule;
|
||||
import com.wix.reactnativenotifications.RNNotificationsPackage;
|
||||
import com.learnium.RNDeviceInfo.RNDeviceModule;
|
||||
import com.imagepicker.ImagePickerModule;
|
||||
import com.psykar.cookiemanager.CookieManagerModule;
|
||||
import com.oblador.vectoricons.VectorIconsModule;
|
||||
import com.wix.reactnativenotifications.RNNotificationsModule;
|
||||
import io.tradle.react.LocalAuthModule;
|
||||
import com.gantix.JailMonkey.JailMonkeyModule;
|
||||
import com.RNFetchBlob.RNFetchBlob;
|
||||
import io.sentry.RNSentryModule;
|
||||
import io.sentry.RNSentryEventEmitter;
|
||||
import com.masteratul.exceptionhandler.ReactNativeExceptionHandlerModule;
|
||||
import com.inprogress.reactnativeyoutube.YouTubeStandaloneModule;
|
||||
import com.reactlibrary.RNReactNativeDocViewerModule;
|
||||
import com.reactnativedocumentpicker.DocumentPicker;
|
||||
import com.oblador.keychain.KeychainModule;
|
||||
import com.reactnativecommunity.asyncstorage.AsyncStorageModule;
|
||||
import com.reactnativecommunity.netinfo.NetInfoModule;
|
||||
import com.reactnativecommunity.webview.RNCWebViewPackage;
|
||||
|
||||
import com.brentvatne.react.ReactVideoPackage;
|
||||
import com.BV.LinearGradient.LinearGradientPackage;
|
||||
import com.horcrux.svg.SvgPackage;
|
||||
import com.swmansion.gesturehandler.react.RNGestureHandlerPackage;
|
||||
|
||||
import com.reactnativenavigation.NavigationApplication;
|
||||
import com.reactnativenavigation.react.NavigationReactNativeHost;
|
||||
import com.reactnativenavigation.react.ReactGateway;
|
||||
import com.wix.reactnativenotifications.RNNotificationsPackage;
|
||||
import com.wix.reactnativenotifications.core.notification.INotificationsApplication;
|
||||
import com.wix.reactnativenotifications.core.notification.IPushNotification;
|
||||
import com.wix.reactnativenotifications.core.notificationdrawer.IPushNotificationsDrawer;
|
||||
@@ -24,8 +51,6 @@ import com.wix.reactnativenotifications.core.AppLaunchHelper;
|
||||
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
|
||||
import com.wix.reactnativenotifications.core.JsIOHelper;
|
||||
|
||||
import com.facebook.react.PackageList;
|
||||
import com.facebook.react.ReactInstanceManager;
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.ReactNativeHost;
|
||||
import com.facebook.react.TurboReactPackage;
|
||||
@@ -41,6 +66,8 @@ import com.facebook.react.module.model.ReactModuleInfoProvider;
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule;
|
||||
import com.facebook.soloader.SoLoader;
|
||||
|
||||
import com.mattermost.share.RealPathUtil;
|
||||
|
||||
public class MainApplication extends NavigationApplication implements INotificationsApplication, INotificationsDrawerApplication {
|
||||
public static MainApplication instance;
|
||||
|
||||
@@ -56,34 +83,74 @@ public class MainApplication extends NavigationApplication implements INotificat
|
||||
|
||||
private Bundle mManagedConfig = null;
|
||||
|
||||
private final ReactNativeHost mReactNativeHost =
|
||||
new ReactNativeHost(this) {
|
||||
@Override
|
||||
public boolean getUseDeveloperSupport() {
|
||||
return BuildConfig.DEBUG;
|
||||
}
|
||||
@Override
|
||||
protected ReactGateway createReactGateway() {
|
||||
ReactNativeHost host = new NavigationReactNativeHost(this, isDebug(), createAdditionalReactPackages()) {
|
||||
@Override
|
||||
protected String getJSMainModuleName() {
|
||||
return "index";
|
||||
}
|
||||
};
|
||||
return new ReactGateway(this, isDebug(), host);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<ReactPackage> getPackages() {
|
||||
@SuppressWarnings("UnnecessaryLocalVariable")
|
||||
List<ReactPackage> packages = new PackageList(this).getPackages();
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// packages.add(new MyReactNativePackage());
|
||||
packages.add(new RNNotificationsPackage(MainApplication.this));
|
||||
packages.add(new RNPasteableTextInputPackage());
|
||||
packages.add(
|
||||
new TurboReactPackage() {
|
||||
@Override
|
||||
public boolean isDebug() {
|
||||
return BuildConfig.DEBUG;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<ReactPackage> createAdditionalReactPackages() {
|
||||
// Add the packages you require here.
|
||||
// No need to add RnnPackage and MainReactPackage
|
||||
return Arrays.<ReactPackage>asList(
|
||||
new TurboReactPackage() {
|
||||
@Override
|
||||
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
|
||||
switch (name) {
|
||||
case "MattermostManaged":
|
||||
return MattermostManagedModule.getInstance(reactContext);
|
||||
case "MattermostShare":
|
||||
return new ShareModule(instance, reactContext);
|
||||
case "RNDeviceInfo":
|
||||
return new RNDeviceModule(reactContext, false);
|
||||
case "ImagePickerManager":
|
||||
return new ImagePickerModule(reactContext, R.style.DefaultExplainingPermissionsTheme);
|
||||
case "RNCookieManagerAndroid":
|
||||
return new CookieManagerModule(reactContext);
|
||||
case "RNVectorIconsModule":
|
||||
return new VectorIconsModule(reactContext);
|
||||
case "WixRNNotifications":
|
||||
return new RNNotificationsModule(instance, reactContext);
|
||||
case "RNLocalAuth":
|
||||
return new LocalAuthModule(reactContext);
|
||||
case "JailMonkey":
|
||||
return new JailMonkeyModule(reactContext);
|
||||
case "RNFetchBlob":
|
||||
return new RNFetchBlob(reactContext);
|
||||
case "MattermostManaged":
|
||||
return MattermostManagedModule.getInstance(reactContext);
|
||||
case "NotificationPreferences":
|
||||
return NotificationPreferencesModule.getInstance(instance, reactContext);
|
||||
case "RNTextInputReset":
|
||||
return new RNTextInputResetModule(reactContext);
|
||||
case "RNSentry":
|
||||
return new RNSentryModule(reactContext);
|
||||
case "RNSentryEventEmitter":
|
||||
return new RNSentryEventEmitter(reactContext);
|
||||
case "ReactNativeExceptionHandler":
|
||||
return new ReactNativeExceptionHandlerModule(reactContext);
|
||||
case "YouTubeStandaloneModule":
|
||||
return new YouTubeStandaloneModule(reactContext);
|
||||
case "RNReactNativeDocViewer":
|
||||
return new RNReactNativeDocViewerModule(reactContext);
|
||||
case "RNDocumentPicker":
|
||||
return new DocumentPicker(reactContext);
|
||||
case "RNKeychainManager":
|
||||
return new KeychainModule(reactContext);
|
||||
case AsyncStorageModule.NAME:
|
||||
return new AsyncStorageModule(reactContext);
|
||||
case NetInfoModule.NAME:
|
||||
return new NetInfoModule(reactContext);
|
||||
default:
|
||||
throw new IllegalArgumentException("Could not find module " + name);
|
||||
}
|
||||
@@ -96,28 +163,38 @@ private final ReactNativeHost mReactNativeHost =
|
||||
public Map<String, ReactModuleInfo> getReactModuleInfos() {
|
||||
Map<String, ReactModuleInfo> map = new HashMap<>();
|
||||
map.put("MattermostManaged", new ReactModuleInfo("MattermostManaged", "com.mattermost.rnbeta.MattermostManagedModule", false, false, false, false, false));
|
||||
map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false));
|
||||
map.put("NotificationPreferences", new ReactModuleInfo("NotificationPreferences", "com.mattermost.rnbeta.NotificationPreferencesModule", false, false, false, false, false));
|
||||
map.put("RNTextInputReset", new ReactModuleInfo("RNTextInputReset", "com.mattermost.rnbeta.RNTextInputResetModule", false, false, false, false, false));
|
||||
|
||||
map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false));
|
||||
map.put("RNDeviceInfo", new ReactModuleInfo("RNDeviceInfo", "com.learnium.RNDeviceInfo.RNDeviceModule", false, false, true, false, false));
|
||||
map.put("ImagePickerManager", new ReactModuleInfo("ImagePickerManager", "com.imagepicker.ImagePickerModule", false, false, false, false, false));
|
||||
map.put("RNCookieManagerAndroid", new ReactModuleInfo("RNCookieManagerAndroid", "com.psykar.cookiemanager.CookieManagerModule", false, false, false, false, false));
|
||||
map.put("RNVectorIconsModule", new ReactModuleInfo("RNVectorIconsModule", "com.oblador.vectoricons.VectorIconsModule", false, false, false, false, false));
|
||||
map.put("WixRNNotifications", new ReactModuleInfo("WixRNNotifications", "com.wix.reactnativenotifications.RNNotificationsModule", false, false, false, false, false));
|
||||
map.put("RNLocalAuth", new ReactModuleInfo("RNLocalAuth", "io.tradle.react.LocalAuthModule", false, false, false, false, false));
|
||||
map.put("JailMonkey", new ReactModuleInfo("JailMonkey", "com.gantix.JailMonkey.JailMonkeyModule", false, false, true, false, false));
|
||||
map.put("RNFetchBlob", new ReactModuleInfo("RNFetchBlob", "com.RNFetchBlob.RNFetchBlob", false, false, true, false, false));
|
||||
map.put("RNSentry", new ReactModuleInfo("RNSentry", "com.sentry.RNSentryModule", false, false, true, false, false));
|
||||
map.put("RNSentryEventEmitter", new ReactModuleInfo("RNSentryEventEmitter", "com.sentry.RNSentryEventEmitter", false, false, true, false, false));
|
||||
map.put("ReactNativeExceptionHandler", new ReactModuleInfo("ReactNativeExceptionHandler", "com.masteratul.exceptionhandler.ReactNativeExceptionHandlerModule", false, false, false, false, false));
|
||||
map.put("YouTubeStandaloneModule", new ReactModuleInfo("YouTubeStandaloneModule", "com.inprogress.reactnativeyoutube.YouTubeStandaloneModule", false, false, false, false, false));
|
||||
map.put("RNReactNativeDocViewer", new ReactModuleInfo("RNReactNativeDocViewer", "com.reactlibrary.RNReactNativeDocViewerModule", false, false, false, false, false));
|
||||
map.put("RNDocumentPicker", new ReactModuleInfo("RNDocumentPicker", "com.reactnativedocumentpicker.DocumentPicker", false, false, false, false, false));
|
||||
map.put("RNKeychainManager", new ReactModuleInfo("RNKeychainManager", "com.oblador.keychain.KeychainModule", false, false, true, false, false));
|
||||
map.put(AsyncStorageModule.NAME, new ReactModuleInfo(AsyncStorageModule.NAME, "com.reactnativecommunity.asyncstorage.AsyncStorageModule", false, false, false, false, false));
|
||||
map.put(NetInfoModule.NAME, new ReactModuleInfo(NetInfoModule.NAME, "com.reactnativecommunity.netinfo.NetInfoModule", false, false, false, false, false));
|
||||
return map;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getJSMainModuleName() {
|
||||
return "index";
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public ReactNativeHost getReactNativeHost() {
|
||||
return mReactNativeHost;
|
||||
},
|
||||
new RNCWebViewPackage(),
|
||||
new SvgPackage(),
|
||||
new LinearGradientPackage(),
|
||||
new ReactVideoPackage(),
|
||||
new RNGestureHandlerPackage()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -125,13 +202,14 @@ private final ReactNativeHost mReactNativeHost =
|
||||
super.onCreate();
|
||||
instance = this;
|
||||
|
||||
registerActivityLifecycleCallbacks(new ManagedActivityLifecycleCallbacks());
|
||||
|
||||
// Delete any previous temp files created by the app
|
||||
File tempFolder = new File(getApplicationContext().getCacheDir(), ShareModule.CACHE_DIR_NAME);
|
||||
File tempFolder = new File(getApplicationContext().getCacheDir(), "mmShare");
|
||||
RealPathUtil.deleteTempFiles(tempFolder);
|
||||
Log.i("ReactNative", "Cleaning temp cache " + tempFolder.getAbsolutePath());
|
||||
|
||||
SoLoader.init(this, /* native exopackage */ false);
|
||||
initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
|
||||
|
||||
// Uncomment to listen to react markers for build that has telemetry enabled
|
||||
// addReactMarkerListener();
|
||||
@@ -154,11 +232,14 @@ private final ReactNativeHost mReactNativeHost =
|
||||
}
|
||||
|
||||
public ReactContext getRunningReactContext() {
|
||||
if (mReactNativeHost == null) {
|
||||
final ReactGateway reactGateway = getReactGateway();
|
||||
|
||||
if (reactGateway == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mReactNativeHost
|
||||
return reactGateway
|
||||
.getReactNativeHost()
|
||||
.getReactInstanceManager()
|
||||
.getCurrentReactContext();
|
||||
}
|
||||
@@ -182,7 +263,7 @@ private final ReactNativeHost mReactNativeHost =
|
||||
}
|
||||
|
||||
public synchronized Bundle getManagedConfig() {
|
||||
if (mManagedConfig != null && mManagedConfig.size() > 0) {
|
||||
if (mManagedConfig!= null && mManagedConfig.size() > 0) {
|
||||
return mManagedConfig;
|
||||
}
|
||||
|
||||
@@ -226,35 +307,4 @@ private final ReactNativeHost mReactNativeHost =
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Flipper in React Native templates. Call this in the onCreate method with something like
|
||||
* initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
|
||||
*
|
||||
* @param context
|
||||
* @param reactInstanceManager
|
||||
*/
|
||||
private static void initializeFlipper(
|
||||
Context context, ReactInstanceManager reactInstanceManager) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
try {
|
||||
/*
|
||||
We use reflection here to pick up the class that initializes Flipper,
|
||||
since Flipper library is not available in release mode
|
||||
*/
|
||||
Class<?> aClass = Class.forName("com.rndiffapp.ReactNativeFlipper");
|
||||
aClass
|
||||
.getMethod("initializeFlipper", Context.class, ReactInstanceManager.class)
|
||||
.invoke(null, context, reactInstanceManager);
|
||||
} catch (ClassNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
} catch (NoSuchMethodException e) {
|
||||
e.printStackTrace();
|
||||
} catch (IllegalAccessException e) {
|
||||
e.printStackTrace();
|
||||
} catch (InvocationTargetException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.app.Activity;
|
||||
import android.app.Application.ActivityLifecycleCallbacks;
|
||||
import android.content.Context;
|
||||
import android.content.RestrictionsManager;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.view.WindowManager;
|
||||
import android.view.WindowManager.LayoutParams;
|
||||
import android.util.ArraySet;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.ReactContext;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule;
|
||||
|
||||
public class ManagedActivityLifecycleCallbacks implements ActivityLifecycleCallbacks {
|
||||
private static final String TAG = ManagedActivityLifecycleCallbacks.class.getSimpleName();
|
||||
|
||||
private final IntentFilter restrictionsFilter =
|
||||
new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED);
|
||||
|
||||
private final BroadcastReceiver restrictionsReceiver = new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context ctx, Intent intent) {
|
||||
if (ctx != null) {
|
||||
Bundle managedConfig = MainApplication.instance.loadManagedConfig(ctx);
|
||||
|
||||
// Check current configuration settings, change your app's UI and
|
||||
// functionality as necessary.
|
||||
Log.i(TAG, "Managed Configuration Changed");
|
||||
sendConfigChanged(managedConfig);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
|
||||
MattermostManagedModule managedModule = MattermostManagedModule.getInstance();
|
||||
if (managedModule != null && managedModule.isBlurAppScreenEnabled() && activity != null) {
|
||||
activity.getWindow().setFlags(LayoutParams.FLAG_SECURE,
|
||||
LayoutParams.FLAG_SECURE);
|
||||
}
|
||||
|
||||
Bundle managedConfig = MainApplication.instance.getManagedConfig();
|
||||
if (managedConfig != null && activity != null) {
|
||||
activity.registerReceiver(restrictionsReceiver, restrictionsFilter);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResumed(Activity activity) {
|
||||
ReactContext ctx = MainApplication.instance.getRunningReactContext();
|
||||
Bundle managedConfig = MainApplication.instance.getManagedConfig();
|
||||
|
||||
if (ctx != null) {
|
||||
Bundle newConfig = MainApplication.instance.loadManagedConfig(ctx);
|
||||
if (!equalBundles(newConfig, managedConfig)) {
|
||||
Log.i(TAG, "onResumed Managed Configuration Changed");
|
||||
sendConfigChanged(newConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityStopped(Activity activity) {
|
||||
Bundle managedConfig = MainApplication.instance.getManagedConfig();
|
||||
|
||||
if (managedConfig != null) {
|
||||
try {
|
||||
activity.unregisterReceiver(restrictionsReceiver);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Just ignore this cause the receiver wasn't registered for this activity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityStarted(Activity activity) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityPaused(Activity activity) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityDestroyed(Activity activity) {
|
||||
}
|
||||
|
||||
private void sendConfigChanged(Bundle config) {
|
||||
WritableMap result = Arguments.createMap();
|
||||
if (config != null) {
|
||||
result = Arguments.fromBundle(config);
|
||||
}
|
||||
ReactContext ctx = MainApplication.instance.getRunningReactContext();
|
||||
|
||||
if (ctx != null) {
|
||||
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
|
||||
.emit("managedConfigDidChange", result);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean equalBundles(Bundle one, Bundle two) {
|
||||
if (one == null || two == null)
|
||||
return false;
|
||||
|
||||
if(one.size() != two.size())
|
||||
return false;
|
||||
|
||||
Set<String> setOne = new ArraySet<String>();
|
||||
setOne.addAll(one.keySet());
|
||||
setOne.addAll(two.keySet());
|
||||
Object valueOne;
|
||||
Object valueTwo;
|
||||
|
||||
for(String key : setOne) {
|
||||
if (!one.containsKey(key) || !two.containsKey(key))
|
||||
return false;
|
||||
|
||||
valueOne = one.get(key);
|
||||
valueTwo = two.get(key);
|
||||
if(valueOne instanceof Bundle && valueTwo instanceof Bundle &&
|
||||
!equalBundles((Bundle) valueOne, (Bundle) valueTwo)) {
|
||||
return false;
|
||||
}
|
||||
else if(valueOne == null) {
|
||||
if(valueTwo != null)
|
||||
return false;
|
||||
}
|
||||
else if(!valueOne.equals(valueTwo))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,8 @@ import android.content.Context;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
|
||||
import com.oblador.keychain.KeychainModule;
|
||||
|
||||
import com.mattermost.react_native_interface.ResolvePromise;
|
||||
|
||||
@@ -4,15 +4,7 @@ import android.app.Activity;
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.view.WindowManager.LayoutParams;
|
||||
import android.util.ArraySet;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.NativeModule;
|
||||
@@ -22,34 +14,14 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.bridge.Promise;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.bridge.LifecycleEventListener;
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule;
|
||||
|
||||
public class MattermostManagedModule extends ReactContextBaseJavaModule implements LifecycleEventListener {
|
||||
public class MattermostManagedModule extends ReactContextBaseJavaModule {
|
||||
private static MattermostManagedModule instance;
|
||||
|
||||
private static final String TAG = MattermostManagedModule.class.getSimpleName();
|
||||
|
||||
private final IntentFilter restrictionsFilter =
|
||||
new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED);
|
||||
|
||||
private final BroadcastReceiver restrictionsReceiver = new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context ctx, Intent intent) {
|
||||
if (ctx != null) {
|
||||
Bundle managedConfig = MainApplication.instance.loadManagedConfig(ctx);
|
||||
|
||||
// Check current configuration settings, change your app's UI and
|
||||
// functionality as necessary.
|
||||
Log.i(TAG, "Managed Configuration Changed");
|
||||
sendConfigChanged(managedConfig);
|
||||
handleBlurScreen(managedConfig);
|
||||
}
|
||||
}
|
||||
};
|
||||
private boolean shouldBlurAppScreen = false;
|
||||
|
||||
private MattermostManagedModule(ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
reactContext.addLifecycleEventListener(this);
|
||||
}
|
||||
|
||||
public static MattermostManagedModule getInstance(ReactApplicationContext reactContext) {
|
||||
@@ -69,6 +41,15 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule implemen
|
||||
return "MattermostManaged";
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void blurAppScreen(boolean enabled) {
|
||||
shouldBlurAppScreen = enabled;
|
||||
}
|
||||
|
||||
public boolean isBlurAppScreenEnabled() {
|
||||
return shouldBlurAppScreen;
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void getConfig(final Promise promise) {
|
||||
try {
|
||||
@@ -88,10 +69,7 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule implemen
|
||||
@ReactMethod
|
||||
// Close the current activity and open the security settings.
|
||||
public void goToSecuritySettings() {
|
||||
Intent intent = new Intent(Settings.ACTION_SECURITY_SETTINGS);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
getReactApplicationContext().startActivity(intent);
|
||||
|
||||
getReactApplicationContext().startActivity(new Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS));
|
||||
getCurrentActivity().finish();
|
||||
System.exit(0);
|
||||
}
|
||||
@@ -114,110 +92,4 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule implemen
|
||||
getCurrentActivity().finish();
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHostResume() {
|
||||
Activity activity = getCurrentActivity();
|
||||
Bundle managedConfig = MainApplication.instance.getManagedConfig();
|
||||
|
||||
if (activity != null && managedConfig != null) {
|
||||
activity.registerReceiver(restrictionsReceiver, restrictionsFilter);
|
||||
}
|
||||
|
||||
ReactContext ctx = MainApplication.instance.getRunningReactContext();
|
||||
Bundle newManagedConfig = null;
|
||||
if (ctx != null) {
|
||||
newManagedConfig = MainApplication.instance.loadManagedConfig(ctx);
|
||||
if (!equalBundles(newManagedConfig, managedConfig)) {
|
||||
Log.i(TAG, "onResumed Managed Configuration Changed");
|
||||
sendConfigChanged(newManagedConfig);
|
||||
}
|
||||
}
|
||||
|
||||
handleBlurScreen(newManagedConfig);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHostPause() {
|
||||
Activity activity = getCurrentActivity();
|
||||
Bundle managedConfig = MainApplication.instance.getManagedConfig();
|
||||
|
||||
if (activity != null && managedConfig != null) {
|
||||
try {
|
||||
activity.unregisterReceiver(restrictionsReceiver);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Just ignore this cause the receiver wasn't registered for this activity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHostDestroy() {
|
||||
}
|
||||
|
||||
private void handleBlurScreen(Bundle config) {
|
||||
Activity activity = getCurrentActivity();
|
||||
boolean blurAppScreen = false;
|
||||
|
||||
if (config != null) {
|
||||
blurAppScreen = Boolean.parseBoolean(config.getString("blurApplicationScreen"));
|
||||
}
|
||||
|
||||
if (blurAppScreen) {
|
||||
activity.getWindow().setFlags(LayoutParams.FLAG_SECURE, LayoutParams.FLAG_SECURE);
|
||||
} else {
|
||||
activity.getWindow().clearFlags(LayoutParams.FLAG_SECURE);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendConfigChanged(Bundle config) {
|
||||
WritableMap result = Arguments.createMap();
|
||||
if (config != null) {
|
||||
result = Arguments.fromBundle(config);
|
||||
}
|
||||
ReactContext ctx = MainApplication.instance.getRunningReactContext();
|
||||
|
||||
if (ctx != null) {
|
||||
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
|
||||
.emit("managedConfigDidChange", result);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean equalBundles(Bundle one, Bundle two) {
|
||||
if (one == null && two == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (one == null || two == null)
|
||||
return false;
|
||||
|
||||
if(one.size() != two.size())
|
||||
return false;
|
||||
|
||||
Set<String> setOne = new ArraySet<String>();
|
||||
setOne.addAll(one.keySet());
|
||||
setOne.addAll(two.keySet());
|
||||
Object valueOne;
|
||||
Object valueTwo;
|
||||
|
||||
for(String key : setOne) {
|
||||
if (!one.containsKey(key) || !two.containsKey(key))
|
||||
return false;
|
||||
|
||||
valueOne = one.get(key);
|
||||
valueTwo = two.get(key);
|
||||
if(valueOne instanceof Bundle && valueTwo instanceof Bundle &&
|
||||
!equalBundles((Bundle) valueOne, (Bundle) valueTwo)) {
|
||||
return false;
|
||||
}
|
||||
else if(valueOne == null) {
|
||||
if(valueTwo != null)
|
||||
return false;
|
||||
}
|
||||
else if(!valueOne.equals(valueTwo))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import android.content.Intent;
|
||||
import android.content.res.Resources;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.util.Log;
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -21,9 +21,6 @@ import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONException;
|
||||
|
||||
import com.mattermost.react_native_interface.ResolvePromise;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
@@ -63,6 +60,7 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
|
||||
String token = map.getString("password");
|
||||
String serverUrl = map.getString("service");
|
||||
|
||||
Log.i("ReactNative", String.format("URL=%s TOKEN=%s", serverUrl, token));
|
||||
replyToMessage(serverUrl, token, notificationId, message);
|
||||
}
|
||||
}
|
||||
@@ -86,22 +84,19 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
|
||||
final OkHttpClient client = new OkHttpClient();
|
||||
final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
|
||||
String json = buildReplyPost(channelId, rootId, message.toString());
|
||||
Log.i("ReactNative", String.format("JSON STRING %s", json));
|
||||
RequestBody body = RequestBody.create(JSON, json);
|
||||
|
||||
String postsEndpoint = "/api/v4/posts?set_online=false";
|
||||
String url = String.format("%s%s", serverUrl.replaceAll("/$", ""), postsEndpoint);
|
||||
Log.i("ReactNative", String.format("Reply URL=%s", url));
|
||||
Request request = new Request.Builder()
|
||||
.header("Authorization", String.format("Bearer %s", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.url(url)
|
||||
.post(body)
|
||||
.build();
|
||||
.header("Authorization", String.format("Bearer %s", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.url(String.format("%s/api/v4/posts", serverUrl.replaceAll("/$", "")))
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
client.newCall(request).enqueue(new okhttp3.Callback() {
|
||||
@Override
|
||||
public void onFailure(Call call, IOException e) {
|
||||
Log.i("ReactNative", String.format("Reply FAILED exception %s", e.getMessage()));
|
||||
Log.i("ReactNative", String.format("Reply with message %s FAILED exception %s", message, e.getMessage()));
|
||||
onReplyFailed(notificationManager, notificationId, channelId);
|
||||
}
|
||||
|
||||
@@ -109,9 +104,9 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
|
||||
public void onResponse(Call call, final Response response) throws IOException {
|
||||
if (response.isSuccessful()) {
|
||||
onReplySuccess(notificationManager, notificationId, channelId);
|
||||
Log.i("ReactNative", "Reply SUCCESS");
|
||||
Log.i("ReactNative", String.format("Reply with message %s", message));
|
||||
} else {
|
||||
Log.i("ReactNative", String.format("Reply FAILED status %s BODY %s", response.code(), response.body().string()));
|
||||
Log.i("ReactNative", String.format("Reply with message %s FAILED status %s BODY %s", message, response.code(), response.body().string()));
|
||||
onReplyFailed(notificationManager, notificationId, channelId);
|
||||
}
|
||||
}
|
||||
@@ -119,15 +114,11 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
|
||||
}
|
||||
|
||||
protected String buildReplyPost(String channelId, String rootId, String message) {
|
||||
try {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("channel_id", channelId);
|
||||
json.put("message", message);
|
||||
json.put("root_id", rootId);
|
||||
return json.toString();
|
||||
} catch(JSONException e) {
|
||||
return "{}";
|
||||
}
|
||||
return "{"
|
||||
+ "\"channel_id\": \"" + channelId + "\","
|
||||
+ "\"message\": \"" + message + "\","
|
||||
+ "\"root_id\": \"" + rootId + "\""
|
||||
+ "}";
|
||||
}
|
||||
|
||||
protected void onReplyFailed(NotificationManager notificationManager, int notificationId, String channelId) {
|
||||
@@ -136,16 +127,12 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
|
||||
String packageName = mContext.getPackageName();
|
||||
int smallIconResId = res.getIdentifier("ic_notification", "mipmap", packageName);
|
||||
|
||||
Bundle userInfoBundle = new Bundle();
|
||||
userInfoBundle.putString("channel_id", channelId);
|
||||
|
||||
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, CHANNEL_ID, NotificationManager.IMPORTANCE_LOW);
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
Notification notification =
|
||||
new Notification.Builder(mContext, CHANNEL_ID)
|
||||
.setContentTitle("Message failed to send.")
|
||||
.setSmallIcon(smallIconResId)
|
||||
.addExtras(userInfoBundle)
|
||||
.build();
|
||||
|
||||
CustomPushNotification.clearNotification(mContext, notificationId, channelId);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
public interface RNEditTextOnPasteListener {
|
||||
void onPaste(Uri itemUri);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.ActionMode;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
|
||||
public class RNPasteableActionCallback implements ActionMode.Callback {
|
||||
|
||||
private RNPasteableEditText mEditText;
|
||||
|
||||
RNPasteableActionCallback(RNPasteableEditText editText) {
|
||||
mEditText = editText;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
||||
Bundle config = MainApplication.instance.getManagedConfig();
|
||||
if (config != null) {
|
||||
WritableMap result = Arguments.fromBundle(config);
|
||||
String copyPasteProtection = result.getString("copyAndPasteProtection");
|
||||
if (copyPasteProtection.equals("true")) {
|
||||
disableMenus(menu);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
|
||||
Uri uri = this.getUriInClipboard();
|
||||
if (item.getItemId() == android.R.id.paste && uri != null) {
|
||||
mEditText.getOnPasteListener().onPaste(uri);
|
||||
mode.finish();
|
||||
} else {
|
||||
mEditText.onTextContextMenuItem(item.getItemId());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyActionMode(ActionMode mode) {
|
||||
|
||||
}
|
||||
|
||||
private void disableMenus(Menu menu) {
|
||||
for (int i = 0; i < menu.size(); i++) {
|
||||
MenuItem item = menu.getItem(i);
|
||||
int id = item.getItemId();
|
||||
boolean shouldDisableMenu = (
|
||||
id == android.R.id.paste
|
||||
|| id == android.R.id.copy
|
||||
|| id == android.R.id.cut
|
||||
);
|
||||
item.setEnabled(!shouldDisableMenu);
|
||||
}
|
||||
}
|
||||
|
||||
private Uri getUriInClipboard() {
|
||||
ClipboardManager clipboardManager = (ClipboardManager) mEditText.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clipData = clipboardManager.getPrimaryClip();
|
||||
if (clipData == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ClipData.Item item = clipData.getItemAt(0);
|
||||
if (item == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String text = item.getText().toString();
|
||||
if (text.length() > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.getUri();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.facebook.react.views.textinput.ReactEditText;
|
||||
|
||||
public class RNPasteableEditText extends ReactEditText {
|
||||
|
||||
private RNEditTextOnPasteListener mOnPasteListener;
|
||||
|
||||
public RNPasteableEditText(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public void setOnPasteListener(RNEditTextOnPasteListener listener) {
|
||||
mOnPasteListener = listener;
|
||||
}
|
||||
|
||||
public RNEditTextOnPasteListener getOnPasteListener() {
|
||||
return mOnPasteListener;
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.res.AssetFileDescriptor;
|
||||
import android.net.Uri;
|
||||
import android.util.Patterns;
|
||||
import android.webkit.MimeTypeMap;
|
||||
import android.webkit.URLUtil;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.ReactContext;
|
||||
import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.uimanager.events.RCTEventEmitter;
|
||||
import com.mattermost.share.RealPathUtil;
|
||||
import com.mattermost.share.ShareModule;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.File;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteListener {
|
||||
|
||||
private RNPasteableEditText mEditText;
|
||||
|
||||
RNPasteableEditTextOnPasteListener(RNPasteableEditText editText) {
|
||||
mEditText = editText;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPaste(Uri itemUri) {
|
||||
ReactContext reactContext = (ReactContext)mEditText.getContext();
|
||||
String uri = itemUri.toString();
|
||||
|
||||
WritableArray images = null;
|
||||
WritableMap error = null;
|
||||
|
||||
String uriMimeType = reactContext.getContentResolver().getType(itemUri);
|
||||
if (uriMimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Special handle for Google docs
|
||||
if (uri.equals("content://com.google.android.apps.docs.editors.kix.editors.clipboard")) {
|
||||
ClipboardManager clipboardManager = (ClipboardManager) reactContext.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clipData = clipboardManager.getPrimaryClip();
|
||||
if (clipData == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ClipData.Item item = clipData.getItemAt(0);
|
||||
String htmlText = item.getHtmlText();
|
||||
// Find uri from html
|
||||
Matcher matcher = Patterns.WEB_URL.matcher(htmlText);
|
||||
if (matcher.find()) {
|
||||
uri = htmlText.substring(matcher.start(1), matcher.end());
|
||||
}
|
||||
}
|
||||
|
||||
if (uri.startsWith("http")) {
|
||||
Thread pastImageFromUrlThread = new Thread(new RNPasteableImageFromUrl(reactContext, mEditText, uri));
|
||||
pastImageFromUrlThread.start();
|
||||
return;
|
||||
}
|
||||
|
||||
uri = RealPathUtil.getRealPathFromURI(reactContext, itemUri);
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get type
|
||||
String extension = MimeTypeMap.getFileExtensionFromUrl(uri);
|
||||
if (extension == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
|
||||
if (mimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get fileName
|
||||
String fileName = URLUtil.guessFileName(uri, null, mimeType);
|
||||
|
||||
if (uri.contains(ShareModule.CACHE_DIR_NAME)) {
|
||||
uri = moveToImagesCache(uri, fileName);
|
||||
}
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get fileSize
|
||||
long fileSize;
|
||||
try {
|
||||
ContentResolver contentResolver = reactContext.getContentResolver();
|
||||
AssetFileDescriptor assetFileDescriptor = contentResolver.openAssetFileDescriptor(itemUri, "r");
|
||||
if (assetFileDescriptor == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
fileSize = assetFileDescriptor.getLength();
|
||||
|
||||
WritableMap image = Arguments.createMap();
|
||||
image.putString("type", mimeType);
|
||||
image.putDouble("fileSize", fileSize);
|
||||
image.putString("fileName", fileName);
|
||||
image.putString("uri", "file://" + uri);
|
||||
|
||||
images = Arguments.createArray();
|
||||
images.pushMap(image);
|
||||
} catch (FileNotFoundException e) {
|
||||
error = Arguments.createMap();
|
||||
error.putString("message", e.getMessage());
|
||||
}
|
||||
|
||||
WritableMap event = Arguments.createMap();
|
||||
event.putArray("data", images);
|
||||
event.putMap("error", error);
|
||||
|
||||
reactContext
|
||||
.getJSModule(RCTEventEmitter.class)
|
||||
.receiveEvent(
|
||||
mEditText.getId(),
|
||||
"onPaste",
|
||||
event
|
||||
);
|
||||
}
|
||||
|
||||
private String moveToImagesCache(String src, String fileName) {
|
||||
ReactContext ctx = (ReactContext)mEditText.getContext();
|
||||
String cacheFolder = ctx.getCacheDir().getAbsolutePath() + "/Images/";
|
||||
String dest = cacheFolder + fileName;
|
||||
File folder = new File(cacheFolder);
|
||||
|
||||
try {
|
||||
if (!folder.exists()) {
|
||||
folder.mkdirs();
|
||||
}
|
||||
|
||||
Files.move(Paths.get(src), Paths.get(dest));
|
||||
} catch (FileAlreadyExistsException fileError) {
|
||||
// Do nothing and return dest path
|
||||
} catch (Exception err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dest;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.ReactContext;
|
||||
import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.uimanager.events.RCTEventEmitter;
|
||||
import com.facebook.react.views.textinput.ReactEditText;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
|
||||
public class RNPasteableImageFromUrl implements Runnable {
|
||||
|
||||
private ReactContext mContext;
|
||||
private String mUri;
|
||||
private ReactEditText mTarget;
|
||||
|
||||
RNPasteableImageFromUrl(ReactContext context, ReactEditText target, String uri) {
|
||||
mContext = context;
|
||||
mUri = uri;
|
||||
mTarget = target;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
WritableArray images = null;
|
||||
WritableMap error = null;
|
||||
|
||||
try {
|
||||
URL url = new URL(mUri);
|
||||
URLConnection u = url.openConnection();
|
||||
|
||||
// Get type
|
||||
String mimeType = u.getHeaderField("Content-Type");
|
||||
if (!mimeType.startsWith("image")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get fileSize
|
||||
long fileSize = Long.parseLong(u.getHeaderField("Content-Length"));
|
||||
|
||||
// Get fileName
|
||||
String contentDisposition = u.getHeaderField("Content-Disposition");
|
||||
int startIndex = contentDisposition.indexOf("filename=\"") + 10;
|
||||
int endIndex = contentDisposition.length() - 1;
|
||||
String fileName = contentDisposition.substring(startIndex, endIndex);
|
||||
|
||||
WritableMap image = Arguments.createMap();
|
||||
image.putString("type", mimeType);
|
||||
image.putDouble("fileSize", fileSize);
|
||||
image.putString("fileName", fileName);
|
||||
image.putString("uri", mUri);
|
||||
|
||||
images = Arguments.createArray();
|
||||
images.pushMap(image);
|
||||
|
||||
} catch (IOException e) {
|
||||
error = Arguments.createMap();
|
||||
error.putString("message", e.getMessage());
|
||||
}
|
||||
|
||||
WritableMap event = Arguments.createMap();
|
||||
event.putArray("data", images);
|
||||
event.putMap("error", error);
|
||||
|
||||
mContext
|
||||
.getJSModule(RCTEventEmitter.class)
|
||||
.receiveEvent(
|
||||
mTarget.getId(),
|
||||
"onPaste",
|
||||
event
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat;
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat;
|
||||
import androidx.core.os.BuildCompat;
|
||||
import android.text.InputType;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
|
||||
import com.facebook.react.common.MapBuilder;
|
||||
import com.facebook.react.uimanager.ThemedReactContext;
|
||||
import com.facebook.react.views.textinput.ReactEditText;
|
||||
import com.facebook.react.views.textinput.ReactTextInputManager;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public class RNPasteableTextInputManager extends ReactTextInputManager {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "PasteableTextInputAndroid";
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReactEditText createViewInstance(ThemedReactContext context) {
|
||||
RNPasteableEditText editText = new RNPasteableEditText(context) {
|
||||
@Override
|
||||
public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
|
||||
final InputConnection ic = super.onCreateInputConnection(editorInfo);
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo,
|
||||
new String [] {"image/*"});
|
||||
|
||||
|
||||
final InputConnectionCompat.OnCommitContentListener callback =
|
||||
(inputContentInfo, flags, opts) -> {
|
||||
// read and display inputContentInfo asynchronously
|
||||
if (BuildCompat.isAtLeastNMR1() && (flags &
|
||||
InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
|
||||
try {
|
||||
inputContentInfo.requestPermission();
|
||||
}
|
||||
catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.getOnPasteListener().onPaste(inputContentInfo.getContentUri());
|
||||
return true;
|
||||
};
|
||||
return InputConnectionCompat.createWrapper(ic, editorInfo, callback);
|
||||
}
|
||||
};
|
||||
int inputType = editText.getInputType();
|
||||
editText.setInputType(inputType & (~InputType.TYPE_TEXT_FLAG_MULTI_LINE));
|
||||
editText.setReturnKeyType("done");
|
||||
editText.setCustomInsertionActionModeCallback(new RNPasteableActionCallback(editText));
|
||||
editText.setCustomSelectionActionModeCallback(new RNPasteableActionCallback(editText));
|
||||
return editText;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addEventEmitters(ThemedReactContext reactContext, ReactEditText editText) {
|
||||
super.addEventEmitters(reactContext, editText);
|
||||
|
||||
RNPasteableEditText pasteableEditText = (RNPasteableEditText)editText;
|
||||
pasteableEditText.setOnPasteListener(new RNPasteableEditTextOnPasteListener(pasteableEditText));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Map<String, Object> getExportedCustomBubblingEventTypeConstants() {
|
||||
Map<String, Object> map = super.getExportedCustomBubblingEventTypeConstants();
|
||||
map.put(
|
||||
"onPaste",
|
||||
MapBuilder.of(
|
||||
"phasedRegistrationNames",
|
||||
MapBuilder.of("bubbled", "onPaste")));
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.bridge.NativeModule;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.uimanager.ViewManager;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public class RNPasteableTextInputPackage implements ReactPackage {
|
||||
@Nonnull
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(@Nonnull ReactApplicationContext reactContext) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public List<ViewManager> createViewManagers(@Nonnull ReactApplicationContext reactContext) {
|
||||
return Arrays.asList(
|
||||
new RNPasteableTextInputManager()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.util.Log;
|
||||
import java.lang.System;
|
||||
|
||||
@@ -12,23 +11,19 @@ import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.HttpUrl;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONException;
|
||||
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
|
||||
import com.mattermost.react_native_interface.ResolvePromise;
|
||||
|
||||
public class ReceiptDelivery {
|
||||
static final String CURRENT_SERVER_URL = "@currentServerUrl";
|
||||
|
||||
private static final int[] FIBONACCI_BACKOFFS = new int[] { 0, 1, 2, 3, 5, 8 };
|
||||
|
||||
public static void send(Context context, final String ackId, final String postId, final String type, final boolean isIdLoaded, ResolvePromise promise) {
|
||||
public static void send (Context context, final String ackId, final String type) {
|
||||
final ReactApplicationContext reactApplicationContext = new ReactApplicationContext(context);
|
||||
|
||||
MattermostCredentialsHelper.getCredentialsForCurrentServer(reactApplicationContext, new ResolvePromise() {
|
||||
@@ -42,31 +37,19 @@ public class ReceiptDelivery {
|
||||
if (map != null) {
|
||||
String token = map.getString("password");
|
||||
String serverUrl = map.getString("service");
|
||||
if (serverUrl.isEmpty()) {
|
||||
String[] credentials = token.split(",[ ]*");
|
||||
if (credentials.length == 2) {
|
||||
token = credentials[0];
|
||||
serverUrl = credentials[1];
|
||||
}
|
||||
}
|
||||
|
||||
Log.i("ReactNative", String.format("Send receipt delivery ACK=%s TYPE=%s to URL=%s with ID-LOADED=%s", ackId, type, serverUrl, isIdLoaded));
|
||||
execute(serverUrl, postId, token, ackId, type, isIdLoaded, promise);
|
||||
Log.i("ReactNative", String.format("Send receipt delivery ACK=%s TYPE=%s to URL=%s with TOKEN=%s", ackId, type, serverUrl, token));
|
||||
execute(serverUrl, token, ackId, type);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected static void execute(String serverUrl, String postId, String token, String ackId, String type, boolean isIdLoaded, ResolvePromise promise) {
|
||||
if (token == null) {
|
||||
promise.reject("Receipt delivery failure", "Invalid token");
|
||||
protected static void execute(String serverUrl, String token, String ackId, String type) {
|
||||
if (token == null || serverUrl == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverUrl == null) {
|
||||
promise.reject("Receipt delivery failure", "Invalid server URL");
|
||||
}
|
||||
|
||||
JSONObject json;
|
||||
long receivedAt = System.currentTimeMillis();
|
||||
|
||||
@@ -76,62 +59,26 @@ public class ReceiptDelivery {
|
||||
json.put("received_at", receivedAt);
|
||||
json.put("platform", "android");
|
||||
json.put("type", type);
|
||||
json.put("post_id", postId);
|
||||
json.put("is_id_loaded", isIdLoaded);
|
||||
} catch (JSONException e) {
|
||||
Log.e("ReactNative", "Receipt delivery failed to build json payload");
|
||||
promise.reject("Receipt delivery failure", e.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
final HttpUrl url = HttpUrl.parse(
|
||||
String.format("%s/api/v4/notifications/ack", serverUrl.replaceAll("/$", "")));
|
||||
if (url != null) {
|
||||
final OkHttpClient client = new OkHttpClient();
|
||||
final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
|
||||
RequestBody body = RequestBody.create(JSON, json.toString());
|
||||
Request request = new Request.Builder()
|
||||
.header("Authorization", String.format("Bearer %s", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.url(url)
|
||||
.post(body)
|
||||
.build();
|
||||
final OkHttpClient client = new OkHttpClient();
|
||||
final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
|
||||
RequestBody body = RequestBody.create(JSON, json.toString());
|
||||
Request request = new Request.Builder()
|
||||
.header("Authorization", String.format("Bearer %s", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.url(String.format("%s/api/v4/notifications/ack", serverUrl.replaceAll("/$", "")))
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
makeServerRequest(client, request, isIdLoaded, 0, promise);
|
||||
}
|
||||
}
|
||||
|
||||
private static void makeServerRequest(OkHttpClient client, Request request, Boolean isIdLoaded, int reRequestCount, ResolvePromise promise) {
|
||||
try {
|
||||
Response response = client.newCall(request).execute();
|
||||
String responseBody = response.body().string();
|
||||
if (response.code() != 200) {
|
||||
throw new Exception(responseBody);
|
||||
}
|
||||
JSONObject jsonResponse = new JSONObject(responseBody);
|
||||
Bundle bundle = new Bundle();
|
||||
String keys[] = new String[]{"post_id", "category", "message", "team_id", "channel_id", "channel_name", "type", "sender_id", "sender_name", "version"};
|
||||
for (int i = 0; i < keys.length; i++) {
|
||||
String key = keys[i];
|
||||
if (jsonResponse.has(key)) {
|
||||
bundle.putString(key, jsonResponse.getString(key));
|
||||
}
|
||||
}
|
||||
promise.resolve(bundle);
|
||||
client.newCall(request).execute();
|
||||
} catch (Exception e) {
|
||||
Log.e("ReactNative", "Receipt delivery failed to send");
|
||||
if (isIdLoaded) {
|
||||
try {
|
||||
reRequestCount++;
|
||||
if (reRequestCount < FIBONACCI_BACKOFFS.length) {
|
||||
Log.i("ReactNative", "Retry attempt " + reRequestCount + " with backoff delay: " + FIBONACCI_BACKOFFS[reRequestCount] + " seconds");
|
||||
Thread.sleep(FIBONACCI_BACKOFFS[reRequestCount] * 1000);
|
||||
makeServerRequest(client, request, isIdLoaded, reRequestCount, promise);
|
||||
}
|
||||
} catch(InterruptedException ie) {}
|
||||
}
|
||||
|
||||
promise.reject("Receipt delivery failure", e.toString());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ public class RealPathUtil {
|
||||
}
|
||||
|
||||
|
||||
File cacheDir = new File(context.getCacheDir(), ShareModule.CACHE_DIR_NAME);
|
||||
File cacheDir = new File(context.getCacheDir(), "mmShare");
|
||||
if (!cacheDir.exists()) {
|
||||
cacheDir.mkdirs();
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
private final OkHttpClient client = new OkHttpClient();
|
||||
public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
|
||||
private final MainApplication mApplication;
|
||||
public static final String CACHE_DIR_NAME = "mmShare";
|
||||
|
||||
public ShareModule(MainApplication application, ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
@@ -68,7 +67,6 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
@Override
|
||||
public Map<String, Object> getConstants() {
|
||||
HashMap<String, Object> constants = new HashMap<>(1);
|
||||
constants.put("cacheDirName", CACHE_DIR_NAME);
|
||||
constants.put("isOpened", mApplication.sharedExtensionIsOpened);
|
||||
mApplication.sharedExtensionIsOpened = false;
|
||||
return constants;
|
||||
@@ -79,7 +77,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
this.clear();
|
||||
Activity currentActivity = getCurrentActivity();
|
||||
if (currentActivity != null) {
|
||||
currentActivity.finishAndRemoveTask();
|
||||
currentActivity.finish();
|
||||
}
|
||||
|
||||
if (data != null && data.hasKey("url")) {
|
||||
@@ -135,7 +133,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
Activity currentActivity = getCurrentActivity();
|
||||
|
||||
if (currentActivity != null) {
|
||||
this.tempFolder = new File(currentActivity.getCacheDir(), CACHE_DIR_NAME);
|
||||
this.tempFolder = new File(currentActivity.getCacheDir(), "mmShare");
|
||||
Intent intent = currentActivity.getIntent();
|
||||
action = intent.getAction();
|
||||
type = intent.getType();
|
||||
@@ -194,12 +192,8 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
JSONObject json = new JSONObject();
|
||||
try {
|
||||
json.put("user_id", data.getString("currentUserId"));
|
||||
if (data.hasKey("channelId")) {
|
||||
json.put("channel_id", data.getString("channelId"));
|
||||
}
|
||||
if (data.hasKey("value")) {
|
||||
json.put("message", data.getString("value"));
|
||||
}
|
||||
json.put("channel_id", data.getString("channelId"));
|
||||
json.put("message", data.getString("value"));
|
||||
} catch (JSONException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
<android.support.percent.PercentRelativeLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
@@ -16,4 +16,4 @@
|
||||
android:adjustViewBounds="true"
|
||||
android:src="@drawable/splash" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</android.support.percent.PercentRelativeLayout>
|
||||
|
||||
@@ -7,9 +7,6 @@ buildscript {
|
||||
compileSdkVersion = 28
|
||||
targetSdkVersion = 28
|
||||
supportLibVersion = "28.0.0"
|
||||
kotlinVersion = "1.3.61"
|
||||
RNNKotlinVersion = kotlinVersion
|
||||
|
||||
}
|
||||
repositories {
|
||||
jcenter()
|
||||
@@ -18,9 +15,8 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.5.2'
|
||||
classpath 'com.android.tools.build:gradle:3.3.1'
|
||||
classpath 'com.google.gms:google-services:4.2.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
@@ -48,23 +44,14 @@ allprojects {
|
||||
jcenter()
|
||||
maven {
|
||||
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
||||
// url "$rootDir/../node_modules/react-native/android"
|
||||
|
||||
// Replace AAR from original RN with AAR from react-native-v8
|
||||
url("$rootDir/../node_modules/react-native-v8/dist")
|
||||
url "$rootDir/../node_modules/react-native/android"
|
||||
}
|
||||
maven {
|
||||
// Local Maven repo containing AARs with JSC library built for Android
|
||||
// url "$rootDir/../node_modules/jsc-android/dist"
|
||||
|
||||
// prebuilt libv8android.so
|
||||
url("$rootDir/../node_modules/v8-android/dist")
|
||||
url "$rootDir/../node_modules/jsc-android/dist"
|
||||
}
|
||||
maven {
|
||||
url "https://www.jitpack.io"
|
||||
}
|
||||
maven {
|
||||
url ("https://dl.bintray.com/rudderstack/rudderstack")
|
||||
url "https://jitpack.io"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,3 @@ org.gradle.jvmargs=-Xmx2048M
|
||||
|
||||
#android.enableAapt2=false
|
||||
#android.useDeprecatedNdk=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
|
||||
# Automatically convert third-party libraries to use AndroidX
|
||||
android.enableJetifier=true
|
||||
|
||||
# Version of flipper SDK to use with React Native
|
||||
FLIPPER_VERSION=0.33.1
|
||||
|
||||
@@ -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.0.1-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-5.2.1-all.zip
|
||||
|
||||
24
android/gradlew
vendored
24
android/gradlew
vendored
@@ -1,21 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
@@ -44,7 +28,7 @@ APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
@@ -125,8 +109,8 @@ if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
@@ -185,4 +169,4 @@ if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||
cd "$(dirname "$0")"
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
exec "$JAVACMD" "$@"
|
||||
|
||||
160
android/gradlew.bat
vendored
160
android/gradlew.bat
vendored
@@ -1,76 +1,84 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
goto fail
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
goto fail
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
set CMD_LINE_ARGS=%*
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
:omega
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
||||
8
android/keystores/BUCK
Normal file
8
android/keystores/BUCK
Normal file
@@ -0,0 +1,8 @@
|
||||
keystore(
|
||||
name = "debug",
|
||||
properties = "debug.keystore.properties",
|
||||
store = "debug.keystore",
|
||||
visibility = [
|
||||
"PUBLIC",
|
||||
],
|
||||
)
|
||||
4
android/keystores/debug.keystore.properties
Normal file
4
android/keystores/debug.keystore.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
key.store=debug.keystore
|
||||
key.alias=androiddebugkey
|
||||
key.store.password=android
|
||||
key.alias.password=android
|
||||
@@ -1,5 +1,47 @@
|
||||
rootProject.name = 'Mattermost'
|
||||
include ':react-native-gesture-handler'
|
||||
project(':react-native-gesture-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-gesture-handler/android')
|
||||
include ':react-native-document-picker'
|
||||
project(':react-native-document-picker').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-document-picker/android')
|
||||
include ':react-native-keychain'
|
||||
project(':react-native-keychain').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-keychain/android')
|
||||
include ':react-native-doc-viewer'
|
||||
project(':react-native-doc-viewer').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-doc-viewer/android')
|
||||
include ':react-native-video'
|
||||
project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android')
|
||||
include ':react-native-youtube'
|
||||
project(':react-native-youtube').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-youtube/android')
|
||||
include ':react-native-sentry'
|
||||
project(':react-native-sentry').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-sentry/android')
|
||||
include ':react-native-exception-handler'
|
||||
project(':react-native-exception-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-exception-handler/android')
|
||||
include ':rn-fetch-blob'
|
||||
project(':rn-fetch-blob').projectDir = new File(rootProject.projectDir, '../node_modules/rn-fetch-blob/android')
|
||||
include ':jail-monkey'
|
||||
project(':jail-monkey').projectDir = new File(rootProject.projectDir, '../node_modules/jail-monkey/android')
|
||||
include ':react-native-local-auth'
|
||||
project(':react-native-local-auth').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-local-auth/android')
|
||||
include ':react-native-navigation'
|
||||
project(':react-native-navigation').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-navigation/lib/android/app/')
|
||||
include ':react-native-image-picker'
|
||||
project(':react-native-image-picker').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-image-picker/android')
|
||||
include ':react-native-device-info'
|
||||
project(':react-native-device-info').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-device-info/android')
|
||||
include ':react-native-cookies'
|
||||
project(':react-native-cookies').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-cookies/android')
|
||||
include ':react-native-vector-icons'
|
||||
project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android')
|
||||
include ':reactnativenotifications'
|
||||
project(':reactnativenotifications').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-notifications/android/app')
|
||||
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
|
||||
project(':reactnativenotifications').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-notifications/android')
|
||||
|
||||
include ':app'
|
||||
include ':react-native-svg'
|
||||
project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android')
|
||||
include ':react-native-linear-gradient'
|
||||
project(':react-native-linear-gradient').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-linear-gradient/android')
|
||||
include ':react-native-webview'
|
||||
project(':react-native-webview').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-webview/android')
|
||||
include ':@react-native-community_async-storage'
|
||||
project(':@react-native-community_async-storage').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/async-storage/android')
|
||||
include ':@react-native-community_netinfo'
|
||||
project(':@react-native-community_netinfo').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/netinfo/android')
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {networkStatusChangedAction} from 'redux-offline';
|
||||
|
||||
import {DeviceTypes} from 'app/constants';
|
||||
|
||||
export function connection(isOnline) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
if (isOnline !== undefined && isOnline !== state.device.connection) { //eslint-disable-line no-undefined
|
||||
dispatch({
|
||||
type: DeviceTypes.CONNECTION_CHANGED,
|
||||
data: isOnline,
|
||||
});
|
||||
}
|
||||
return async (dispatch) => {
|
||||
dispatch(networkStatusChangedAction(isOnline));
|
||||
dispatch({
|
||||
type: DeviceTypes.CONNECTION_CHANGED,
|
||||
data: isOnline,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable no-import-assign */
|
||||
|
||||
import {Client4} from '@mm-redux/client';
|
||||
|
||||
import {Preferences} from '@mm-redux/constants';
|
||||
import {PreferenceTypes} from '@mm-redux/action_types';
|
||||
|
||||
import * as CommonSelectors from '@mm-redux/selectors/entities/common';
|
||||
import * as PreferenceSelectors from '@mm-redux/selectors/entities/preferences';
|
||||
import * as PreferenceUtils from '@mm-redux/utils/preference_utils';
|
||||
|
||||
import {
|
||||
makeDirectChannelVisibleIfNecessary,
|
||||
makeGroupMessageVisibleIfNecessary,
|
||||
} from './channels';
|
||||
|
||||
describe('Actions.Helpers.Channels', () => {
|
||||
describe('makeDirectChannelVisibleIfNecessary', () => {
|
||||
const state = {};
|
||||
const currentUserId = 'current-user-id';
|
||||
const otherUserId = 'other-user-id';
|
||||
|
||||
CommonSelectors.getCurrentUserId = jest.fn().mockReturnValue(currentUserId);
|
||||
PreferenceSelectors.getMyPreferences = jest.fn();
|
||||
PreferenceUtils.getPreferenceKey = jest.fn();
|
||||
Client4.savePreferences = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
PreferenceSelectors.getMyPreferences.mockClear();
|
||||
PreferenceUtils.getPreferenceKey.mockClear();
|
||||
Client4.savePreferences.mockClear();
|
||||
});
|
||||
|
||||
it('makes direct channel visible when visibility preference does not exist', () => {
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({});
|
||||
|
||||
const expectedResult = {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [{
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
|
||||
name: otherUserId,
|
||||
value: 'true',
|
||||
}],
|
||||
};
|
||||
|
||||
const result = makeDirectChannelVisibleIfNecessary(state, otherUserId);
|
||||
expect(result).toStrictEqual(expectedResult);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUserId);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledTimes(1);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledWith(currentUserId, expectedResult.data);
|
||||
});
|
||||
|
||||
it('makes direct channel visible when visibilty preference is false', () => {
|
||||
const preference = {value: 'false'};
|
||||
const preferenceKey = 'preference-key';
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({
|
||||
[preferenceKey]: preference,
|
||||
});
|
||||
PreferenceUtils.getPreferenceKey.mockReturnValueOnce(preferenceKey);
|
||||
|
||||
const expectedResult = {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [{
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
|
||||
name: otherUserId,
|
||||
value: 'true',
|
||||
}],
|
||||
};
|
||||
|
||||
const result = makeDirectChannelVisibleIfNecessary(state, otherUserId);
|
||||
expect(result).toStrictEqual(expectedResult);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUserId);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledTimes(1);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledWith(currentUserId, expectedResult.data);
|
||||
});
|
||||
|
||||
it('does nothing if direct channel visibility preference is true', () => {
|
||||
const preference = {value: 'true'};
|
||||
const preferenceKey = 'preference-key';
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({
|
||||
[preferenceKey]: preference,
|
||||
});
|
||||
PreferenceUtils.getPreferenceKey.mockReturnValueOnce(preferenceKey);
|
||||
|
||||
const result = makeDirectChannelVisibleIfNecessary(state, otherUserId);
|
||||
expect(result).toEqual(null);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUserId);
|
||||
expect(Client4.savePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeGroupMessageVisibleIfNecessary', () => {
|
||||
const state = {};
|
||||
const currentUserId = 'current-user-id';
|
||||
const channelId = 'channel-id';
|
||||
|
||||
CommonSelectors.getCurrentUserId = jest.fn().mockReturnValue(currentUserId);
|
||||
PreferenceSelectors.getMyPreferences = jest.fn();
|
||||
PreferenceUtils.getPreferenceKey = jest.fn();
|
||||
Client4.savePreferences = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
PreferenceSelectors.getMyPreferences.mockClear();
|
||||
PreferenceUtils.getPreferenceKey.mockClear();
|
||||
Client4.savePreferences.mockClear();
|
||||
});
|
||||
|
||||
it('makes group channel visible when visibility preference does not exist', async () => {
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({});
|
||||
|
||||
const expectedPreferenceResult = {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [{
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_GROUP_CHANNEL_SHOW,
|
||||
name: channelId,
|
||||
value: 'true',
|
||||
}],
|
||||
};
|
||||
|
||||
const result = await makeGroupMessageVisibleIfNecessary(state, channelId);
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result[1]).toStrictEqual(expectedPreferenceResult);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledTimes(1);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledWith(currentUserId, expectedPreferenceResult.data);
|
||||
});
|
||||
|
||||
it('makes group channel visible when visibilty preference is false', async () => {
|
||||
const preference = {value: 'false'};
|
||||
const preferenceKey = 'preference-key';
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({
|
||||
[preferenceKey]: preference,
|
||||
});
|
||||
PreferenceUtils.getPreferenceKey.mockReturnValueOnce(preferenceKey);
|
||||
|
||||
const expectedPreferenceResult = {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [{
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_GROUP_CHANNEL_SHOW,
|
||||
name: channelId,
|
||||
value: 'true',
|
||||
}],
|
||||
};
|
||||
|
||||
const result = await makeGroupMessageVisibleIfNecessary(state, channelId);
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result[1]).toStrictEqual(expectedPreferenceResult);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledTimes(1);
|
||||
expect(Client4.savePreferences).toHaveBeenCalledWith(currentUserId, expectedPreferenceResult.data);
|
||||
});
|
||||
|
||||
it('does nothing if group channel visibility preference is true', async () => {
|
||||
const preference = {value: 'true'};
|
||||
const preferenceKey = 'preference-key';
|
||||
PreferenceSelectors.getMyPreferences.mockReturnValueOnce({
|
||||
[preferenceKey]: preference,
|
||||
});
|
||||
PreferenceUtils.getPreferenceKey.mockReturnValueOnce(preferenceKey);
|
||||
|
||||
const result = await makeGroupMessageVisibleIfNecessary(state, channelId);
|
||||
expect(result).toEqual(null);
|
||||
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledTimes(1);
|
||||
expect(PreferenceUtils.getPreferenceKey).toHaveBeenCalledWith(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId);
|
||||
expect(Client4.savePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,385 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ChannelTypes, PreferenceTypes, RoleTypes, UserTypes} from '@mm-redux/action_types';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {General, Preferences} from '@mm-redux/constants';
|
||||
import {getCurrentChannelId} 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 {getUserIdFromChannelName, isAutoClosed} from '@mm-redux/utils/channel_utils';
|
||||
import {getPreferenceKey} from '@mm-redux/utils/preference_utils';
|
||||
|
||||
import {ActionResult, GenericAction} from '@mm-redux/types/actions';
|
||||
import {Channel, ChannelMembership} from '@mm-redux/types/channels';
|
||||
import {PreferenceType} from '@mm-redux/types/preferences';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {UserProfile} from '@mm-redux/types/users';
|
||||
import {RelationOneToMany} from '@mm-redux/types/utilities';
|
||||
|
||||
import {isDirectChannelVisible, isGroupChannelVisible} from '@utils/channels';
|
||||
import {buildPreference} from '@utils/preferences';
|
||||
|
||||
export async function loadSidebarDirectMessagesProfiles(state: GlobalState, channels: Array<Channel>, channelMembers: Array<ChannelMembership>) {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const usersInChannel: RelationOneToMany<Channel, UserProfile> = getUserIdsInChannels(state);
|
||||
const directChannels = Object.values(channels).filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
|
||||
const prefs: Array<PreferenceType> = [];
|
||||
const promises: Array<Promise<ActionResult>> = []; //only fetch profiles that we don't have and the Direct channel should be visible
|
||||
const actions = [];
|
||||
const userIds: Array<string> = [];
|
||||
|
||||
// Prepare preferences and start fetching profiles to batch them
|
||||
directChannels.forEach((c) => {
|
||||
const profileIds = Array.from(usersInChannel[c.id] || []);
|
||||
const profilesInChannel: Array<string> = profileIds.filter((u: string) => u !== currentUserId);
|
||||
userIds.push(...profilesInChannel);
|
||||
|
||||
switch (c.type) {
|
||||
case General.DM_CHANNEL: {
|
||||
const dm = fetchDirectMessageProfileIfNeeded(state, c, channelMembers, profilesInChannel);
|
||||
if (dm.preferences.length) {
|
||||
prefs.push(...dm.preferences);
|
||||
}
|
||||
|
||||
if (dm.promise) {
|
||||
promises.push(dm.promise);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case General.GM_CHANNEL: {
|
||||
const gm = fetchGroupMessageProfilesIfNeeded(state, c, channelMembers, profilesInChannel);
|
||||
|
||||
if (gm.preferences.length) {
|
||||
prefs.push(...gm.preferences);
|
||||
}
|
||||
|
||||
if (gm.promise) {
|
||||
promises.push(gm.promise);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save preferences if there are any changes
|
||||
if (prefs.length) {
|
||||
Client4.savePreferences(currentUserId, prefs);
|
||||
actions.push({
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: prefs,
|
||||
});
|
||||
}
|
||||
|
||||
const profilesAction = await getProfilesFromPromises(promises);
|
||||
const userIdsSet: Set<string> = new Set(userIds);
|
||||
|
||||
if (profilesAction) {
|
||||
actions.push(profilesAction);
|
||||
profilesAction.data.forEach((d: any) => {
|
||||
const {users} = d.data;
|
||||
users.forEach((u: UserProfile) => userIdsSet.add(u.id));
|
||||
});
|
||||
}
|
||||
|
||||
if (userIdsSet.size > 0) {
|
||||
try {
|
||||
const statuses = await Client4.getStatusesByIds(Array.from(userIdsSet));
|
||||
if (statuses.length) {
|
||||
actions.push({
|
||||
type: UserTypes.RECEIVED_STATUSES,
|
||||
data: statuses,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// do nothing (status will get fetched later on regardless)
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function fetchMyChannel(channelId: string) {
|
||||
try {
|
||||
const data = await Client4.getChannel(channelId);
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMyChannelMember(channelId: string) {
|
||||
try {
|
||||
const data = await Client4.getMyChannelMember(channelId);
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export function markChannelAsUnread(state: GlobalState, teamId: string, channelId: string, mentions: Array<string>): Array<GenericAction> {
|
||||
const {myMembers} = state.entities.channels;
|
||||
const {currentUserId} = state.entities.users;
|
||||
|
||||
const actions: GenericAction[] = [{
|
||||
type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
amount: 1,
|
||||
},
|
||||
}, {
|
||||
type: ChannelTypes.INCREMENT_UNREAD_MSG_COUNT,
|
||||
data: {
|
||||
teamId,
|
||||
channelId,
|
||||
amount: 1,
|
||||
onlyMentions: myMembers[channelId] && myMembers[channelId].notify_props &&
|
||||
myMembers[channelId].notify_props.mark_unread === General.MENTION,
|
||||
},
|
||||
}];
|
||||
|
||||
if (mentions && mentions.indexOf(currentUserId) !== -1) {
|
||||
actions.push({
|
||||
type: ChannelTypes.INCREMENT_UNREAD_MENTION_COUNT,
|
||||
data: {
|
||||
teamId,
|
||||
channelId,
|
||||
amount: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function makeDirectChannelVisibleIfNecessary(state: GlobalState, otherUserId: string): GenericAction|null {
|
||||
const myPreferences = getMyPreferences(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
|
||||
let preference = myPreferences[getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUserId)];
|
||||
|
||||
if (!preference || preference.value === 'false') {
|
||||
preference = {
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
|
||||
name: otherUserId,
|
||||
value: 'true',
|
||||
};
|
||||
|
||||
Client4.savePreferences(currentUserId, [preference]);
|
||||
return {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [preference],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function makeGroupMessageVisibleIfNecessary(state: GlobalState, channelId: string) {
|
||||
try {
|
||||
const myPreferences = getMyPreferences(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
|
||||
let preference = myPreferences[getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId)];
|
||||
|
||||
if (!preference || preference.value === 'false') {
|
||||
preference = {
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_GROUP_CHANNEL_SHOW,
|
||||
name: channelId,
|
||||
value: 'true',
|
||||
};
|
||||
|
||||
Client4.savePreferences(currentUserId, [preference]);
|
||||
|
||||
const profilesInChannel = await fetchUsersInChannel(state, channelId);
|
||||
|
||||
return [{
|
||||
type: UserTypes.RECEIVED_BATCHED_PROFILES_IN_CHANNEL,
|
||||
data: [profilesInChannel],
|
||||
}, {
|
||||
type: PreferenceTypes.RECEIVED_PREFERENCES,
|
||||
data: [preference],
|
||||
}];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchChannelAndMyMember(channelId: string): Promise<Array<GenericAction>> {
|
||||
const actions: Array<GenericAction> = [];
|
||||
|
||||
try {
|
||||
const [channel, member] = await Promise.all([
|
||||
Client4.getChannel(channelId),
|
||||
Client4.getMyChannelMember(channelId),
|
||||
]);
|
||||
|
||||
actions.push({
|
||||
type: ChannelTypes.RECEIVED_CHANNEL,
|
||||
data: channel,
|
||||
},
|
||||
{
|
||||
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
|
||||
data: member,
|
||||
});
|
||||
|
||||
const roles = await Client4.getRolesByNames(member.roles.split(' '));
|
||||
if (roles.length) {
|
||||
actions.push({
|
||||
type: RoleTypes.RECEIVED_ROLES,
|
||||
data: roles,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function getAddedDmUsersIfNecessary(state: GlobalState, preferences: PreferenceType[]): Promise<Array<GenericAction>> {
|
||||
const userIds: string[] = [];
|
||||
const actions: Array<GenericAction> = [];
|
||||
|
||||
for (const preference of preferences) {
|
||||
if (preference.category === Preferences.CATEGORY_DIRECT_CHANNEL_SHOW && preference.value === 'true') {
|
||||
userIds.push(preference.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (userIds.length !== 0) {
|
||||
const profiles = getUsers(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
|
||||
const needProfiles: string[] = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (!profiles[userId] && userId !== currentUserId) {
|
||||
needProfiles.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (needProfiles.length > 0) {
|
||||
const data = await Client4.getProfilesByIds(userIds);
|
||||
if (profiles.lenght) {
|
||||
actions.push({
|
||||
type: UserTypes.RECEIVED_PROFILES_LIST,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function fetchDirectMessageProfileIfNeeded(state: GlobalState, channel: Channel, channelMembers: Array<ChannelMembership>, profilesInChannel: Array<string>) {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const myPreferences = getMyPreferences(state);
|
||||
const users = getUsers(state);
|
||||
const config = getConfig(state);
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
const otherUserId = getUserIdFromChannelName(currentUserId, channel.name);
|
||||
const otherUser = users[otherUserId];
|
||||
const dmVisible = isDirectChannelVisible(currentUserId, myPreferences, channel);
|
||||
const dmAutoClosed = isAutoClosed(config, myPreferences, channel, channel.last_post_at, otherUser ? otherUser.delete_at : 0, currentChannelId);
|
||||
const member = channelMembers.find((cm) => cm.channel_id === channel.id);
|
||||
const dmIsUnread = member ? member.mention_count > 0 : false;
|
||||
const dmFetchProfile = dmIsUnread || (dmVisible && !dmAutoClosed);
|
||||
const preferences = [];
|
||||
|
||||
// when then DM is hidden but has new messages
|
||||
if ((!dmVisible || dmAutoClosed) && dmIsUnread) {
|
||||
preferences.push(buildPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, currentUserId, otherUserId));
|
||||
preferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
|
||||
}
|
||||
|
||||
if (dmFetchProfile && !profilesInChannel.includes(otherUserId) && otherUserId !== currentUserId) {
|
||||
return {
|
||||
preferences,
|
||||
promise: fetchUsersInChannel(state, channel.id),
|
||||
};
|
||||
}
|
||||
|
||||
return {preferences};
|
||||
}
|
||||
|
||||
function fetchGroupMessageProfilesIfNeeded(state: GlobalState, channel: Channel, channelMembers: Array<ChannelMembership>, profilesInChannel: Array<string>) {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const myPreferences = getMyPreferences(state);
|
||||
const config = getConfig(state);
|
||||
const gmVisible = isGroupChannelVisible(myPreferences, channel);
|
||||
const gmAutoClosed = isAutoClosed(config, myPreferences, channel, channel.last_post_at, 0);
|
||||
const channelMember = channelMembers.find((cm) => cm.channel_id === channel.id);
|
||||
let hasMentions = false;
|
||||
let isUnread = false;
|
||||
|
||||
if (channelMember) {
|
||||
hasMentions = channelMember.mention_count > 0;
|
||||
isUnread = channelMember.msg_count < channel.total_msg_count;
|
||||
}
|
||||
|
||||
const gmIsUnread = hasMentions || isUnread;
|
||||
const gmFetchProfile = gmIsUnread || (gmVisible && !gmAutoClosed);
|
||||
const preferences = [];
|
||||
|
||||
// when then GM is hidden but has new messages
|
||||
if ((!gmVisible || gmAutoClosed) && gmIsUnread) {
|
||||
preferences.push(buildPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, currentUserId, channel.id));
|
||||
preferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
|
||||
}
|
||||
|
||||
if (gmFetchProfile && !profilesInChannel.length) {
|
||||
return {
|
||||
preferences,
|
||||
promise: fetchUsersInChannel(state, channel.id),
|
||||
};
|
||||
}
|
||||
|
||||
return {preferences};
|
||||
}
|
||||
|
||||
async function fetchUsersInChannel(state: GlobalState, channelId: string): Promise<ActionResult> {
|
||||
try {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const profiles = await Client4.getProfilesInChannel(channelId);
|
||||
|
||||
// When fetching profiles in channels we exclude our own user
|
||||
const users = profiles.filter((p: UserProfile) => p.id !== currentUserId);
|
||||
const data = {
|
||||
channelId,
|
||||
users,
|
||||
};
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
async function getProfilesFromPromises(promises: Array<Promise<ActionResult>>): Promise<GenericAction | null> {
|
||||
// Get the profiles returned by the promises
|
||||
if (!promises.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Promise.all(promises);
|
||||
const data = result.filter((p: any) => !p.error);
|
||||
|
||||
return {
|
||||
type: UserTypes.RECEIVED_BATCHED_PROFILES_IN_CHANNEL,
|
||||
data,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
394
app/actions/navigation.js
Normal file
394
app/actions/navigation.js
Normal file
@@ -0,0 +1,394 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Platform} from 'react-native';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
|
||||
import merge from 'deepmerge';
|
||||
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import EphemeralStore from 'app/store/ephemeral_store';
|
||||
|
||||
export function resetToChannel(passProps = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const theme = getTheme(getState());
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
name: 'Channel',
|
||||
passProps,
|
||||
options: {
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function resetToSelectServer(allowOtherServers) {
|
||||
return (dispatch, getState) => {
|
||||
const theme = getTheme(getState());
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
name: 'SelectServer',
|
||||
passProps: {
|
||||
allowOtherServers,
|
||||
},
|
||||
options: {
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
visible: false,
|
||||
height: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function resetToTeams(name, title, passProps = {}, options = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const theme = getTheme(getState());
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
visible: true,
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: title,
|
||||
},
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function goToScreen(name, title, passProps = {}, options = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
const theme = getTheme(state);
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
visible: true,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: title,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Navigation.push(componentId, {
|
||||
component: {
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function popTopScreen(screenId) {
|
||||
return () => {
|
||||
if (screenId) {
|
||||
Navigation.pop(screenId);
|
||||
} else {
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
Navigation.pop(componentId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function popToRoot() {
|
||||
return () => {
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
Navigation.popToRoot(componentId).catch(() => {
|
||||
// RNN returns a promise rejection if there are no screens
|
||||
// atop the root screen to pop. We'll do nothing in this
|
||||
// case but we will catch the rejection here so that the
|
||||
// caller doesn't have to.
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function showModal(name, title, passProps = {}, options = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const theme = getTheme(getState());
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
visible: true,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: title,
|
||||
},
|
||||
leftButtonColor: theme.sidebarHeaderTextColor,
|
||||
rightButtonColor: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
};
|
||||
|
||||
Navigation.showModal({
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
}],
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function showModalOverCurrentContext(name, passProps = {}, options = {}) {
|
||||
return (dispatch) => {
|
||||
const title = '';
|
||||
const animationsEnabled = (Platform.OS === 'android').toString();
|
||||
const defaultOptions = {
|
||||
modalPresentationStyle: 'overCurrentContext',
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
},
|
||||
animations: {
|
||||
showModal: {
|
||||
enabled: animationsEnabled,
|
||||
alpha: {
|
||||
from: 0,
|
||||
to: 1,
|
||||
duration: 250,
|
||||
},
|
||||
},
|
||||
dismissModal: {
|
||||
enabled: animationsEnabled,
|
||||
alpha: {
|
||||
from: 1,
|
||||
to: 0,
|
||||
duration: 250,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mergeOptions = merge(defaultOptions, options);
|
||||
|
||||
dispatch(showModal(name, title, passProps, mergeOptions));
|
||||
};
|
||||
}
|
||||
|
||||
export function showSearchModal(initialValue = '') {
|
||||
return (dispatch) => {
|
||||
const name = 'Search';
|
||||
const title = '';
|
||||
const passProps = {initialValue};
|
||||
const options = {
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
},
|
||||
};
|
||||
|
||||
dispatch(showModal(name, title, passProps, options));
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissModal(options = {}) {
|
||||
return () => {
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
Navigation.dismissModal(componentId, options).catch(() => {
|
||||
// RNN returns a promise rejection if there is no modal to
|
||||
// dismiss. We'll do nothing in this case but we will catch
|
||||
// the rejection here so that the caller doesn't have to.
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissAllModals(options = {}) {
|
||||
return () => {
|
||||
Navigation.dismissAllModals(options).catch(() => {
|
||||
// RNN returns a promise rejection if there are no modals to
|
||||
// dismiss. We'll do nothing in this case but we will catch
|
||||
// the rejection here so that the caller doesn't have to.
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function peek(name, passProps = {}, options = {}) {
|
||||
return () => {
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
const defaultOptions = {
|
||||
preview: {
|
||||
commit: false,
|
||||
},
|
||||
};
|
||||
|
||||
Navigation.push(componentId, {
|
||||
component: {
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function setButtons(componentId, buttons = {leftButtons: [], rightButtons: []}) {
|
||||
return () => {
|
||||
Navigation.mergeOptions(componentId, {
|
||||
topBar: {
|
||||
...buttons,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function showOverlay(name, passProps, options = {}) {
|
||||
return () => {
|
||||
const defaultOptions = {
|
||||
overlay: {
|
||||
interceptTouchOutside: false,
|
||||
},
|
||||
};
|
||||
|
||||
Navigation.showOverlay({
|
||||
component: {
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissOverlay(componentId) {
|
||||
return () => {
|
||||
return Navigation.dismissOverlay(componentId).catch(() => {
|
||||
// RNN returns a promise rejection if there is no modal with
|
||||
// this componentId to dismiss. We'll do nothing in this case
|
||||
// but we will catch the rejection here so that the caller
|
||||
// doesn't have to.
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTheme(componentId, skipBackButtonStyle = false) {
|
||||
return (dispatch, getState) => {
|
||||
const theme = getTheme(getState());
|
||||
|
||||
let backButton = {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
};
|
||||
|
||||
if (skipBackButtonStyle && Platform.OS === 'android') {
|
||||
backButton = null;
|
||||
}
|
||||
|
||||
Navigation.mergeOptions(componentId, {
|
||||
topBar: {
|
||||
backButton,
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Keyboard, Platform} from 'react-native';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
import merge from 'deepmerge';
|
||||
|
||||
import {Preferences} from '@mm-redux/constants';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import Store from '@store/store';
|
||||
|
||||
const CHANNEL_SCREEN = 'Channel';
|
||||
|
||||
function getThemeFromState() {
|
||||
const state = Store.redux?.getState() || {};
|
||||
|
||||
return getTheme(state);
|
||||
}
|
||||
|
||||
export function resetToChannel(passProps = {}) {
|
||||
const theme = getThemeFromState();
|
||||
|
||||
EphemeralStore.clearNavigationComponents();
|
||||
|
||||
const stack = {
|
||||
children: [{
|
||||
component: {
|
||||
id: CHANNEL_SCREEN,
|
||||
name: CHANNEL_SCREEN,
|
||||
passProps,
|
||||
options: {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
backButton: {
|
||||
visible: false,
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
let platformStack = {stack};
|
||||
if (Platform.OS === 'android') {
|
||||
platformStack = {
|
||||
sideMenu: {
|
||||
left: {
|
||||
component: {
|
||||
id: 'MainSidebar',
|
||||
name: 'MainSidebar',
|
||||
},
|
||||
},
|
||||
center: {
|
||||
stack,
|
||||
},
|
||||
right: {
|
||||
component: {
|
||||
id: 'SettingsSidebar',
|
||||
name: 'SettingsSidebar',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
...platformStack,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resetToSelectServer(allowOtherServers) {
|
||||
const theme = Preferences.THEMES.default;
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: 'SelectServer',
|
||||
name: 'SelectServer',
|
||||
passProps: {
|
||||
allowOtherServers,
|
||||
},
|
||||
options: {
|
||||
layout: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
visible: false,
|
||||
height: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resetToTeams(name, title, passProps = {}, options = {}) {
|
||||
const theme = getThemeFromState();
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
visible: true,
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: title,
|
||||
},
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function goToScreen(name, title, passProps = {}, options = {}) {
|
||||
const theme = getThemeFromState();
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
popGesture: true,
|
||||
sideMenu: {
|
||||
left: {enabled: false},
|
||||
right: {enabled: false},
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
visible: true,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: title,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Navigation.push(componentId, {
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function popTopScreen(screenId) {
|
||||
if (screenId) {
|
||||
Navigation.pop(screenId);
|
||||
} else {
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
Navigation.pop(componentId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function popToRoot() {
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
try {
|
||||
await Navigation.popToRoot(componentId);
|
||||
} catch (error) {
|
||||
// RNN returns a promise rejection if there are no screens
|
||||
// atop the root screen to pop. We'll do nothing in this case.
|
||||
}
|
||||
}
|
||||
|
||||
export function showModal(name, title, passProps = {}, options = {}) {
|
||||
const theme = getThemeFromState();
|
||||
const defaultOptions = {
|
||||
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
visible: true,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: title,
|
||||
},
|
||||
leftButtonColor: theme.sidebarHeaderTextColor,
|
||||
rightButtonColor: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
};
|
||||
|
||||
Navigation.showModal({
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
}],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function showModalOverCurrentContext(name, passProps = {}, options = {}) {
|
||||
const title = '';
|
||||
const animationsEnabled = (Platform.OS === 'android').toString();
|
||||
const defaultOptions = {
|
||||
modalPresentationStyle: 'overCurrentContext',
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
componentBackgroundColor: 'transparent',
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
},
|
||||
animations: {
|
||||
showModal: {
|
||||
waitForRender: true,
|
||||
enabled: animationsEnabled,
|
||||
alpha: {
|
||||
from: 0,
|
||||
to: 1,
|
||||
duration: 250,
|
||||
},
|
||||
},
|
||||
dismissModal: {
|
||||
enabled: animationsEnabled,
|
||||
alpha: {
|
||||
from: 1,
|
||||
to: 0,
|
||||
duration: 250,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const mergeOptions = merge(defaultOptions, options);
|
||||
|
||||
showModal(name, title, passProps, mergeOptions);
|
||||
}
|
||||
|
||||
export function showSearchModal(initialValue = '') {
|
||||
const name = 'Search';
|
||||
const title = '';
|
||||
const passProps = {initialValue};
|
||||
const options = {
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
},
|
||||
};
|
||||
|
||||
showModal(name, title, passProps, options);
|
||||
}
|
||||
|
||||
export async function dismissModal(options = {}) {
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
try {
|
||||
await Navigation.dismissModal(componentId, options);
|
||||
} catch (error) {
|
||||
// RNN returns a promise rejection if there is no modal to
|
||||
// dismiss. We'll do nothing in this case.
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissAllModals(options = {}) {
|
||||
try {
|
||||
await Navigation.dismissAllModals(options);
|
||||
} catch (error) {
|
||||
// RNN returns a promise rejection if there are no modals to
|
||||
// dismiss. We'll do nothing in this case.
|
||||
}
|
||||
}
|
||||
|
||||
export function setButtons(componentId, buttons = {leftButtons: [], rightButtons: []}) {
|
||||
const options = {
|
||||
topBar: {
|
||||
...buttons,
|
||||
},
|
||||
};
|
||||
|
||||
mergeNavigationOptions(componentId, options);
|
||||
}
|
||||
|
||||
export function mergeNavigationOptions(componentId, options) {
|
||||
Navigation.mergeOptions(componentId, options);
|
||||
}
|
||||
|
||||
export function showOverlay(name, passProps, options = {}) {
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
componentBackgroundColor: 'transparent',
|
||||
},
|
||||
overlay: {
|
||||
interceptTouchOutside: false,
|
||||
},
|
||||
};
|
||||
|
||||
Navigation.showOverlay({
|
||||
component: {
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function dismissOverlay(componentId) {
|
||||
try {
|
||||
await Navigation.dismissOverlay(componentId);
|
||||
} catch (error) {
|
||||
// RNN returns a promise rejection if there is no modal with
|
||||
// this componentId to dismiss. We'll do nothing in this case.
|
||||
}
|
||||
}
|
||||
|
||||
export function openMainSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(componentId, {
|
||||
sideMenu: {
|
||||
left: {visible: true},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function closeMainSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(CHANNEL_SCREEN, {
|
||||
sideMenu: {
|
||||
left: {visible: false},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function enableMainSideMenu(enabled, visible = true) {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.mergeOptions(CHANNEL_SCREEN, {
|
||||
sideMenu: {
|
||||
left: {enabled, visible},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function openSettingsSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(CHANNEL_SCREEN, {
|
||||
sideMenu: {
|
||||
right: {visible: true},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function closeSettingsSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(CHANNEL_SCREEN, {
|
||||
sideMenu: {
|
||||
right: {visible: false},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,481 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Platform} from 'react-native';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import merge from 'deepmerge';
|
||||
|
||||
import * 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';
|
||||
|
||||
jest.unmock('@actions/navigation');
|
||||
jest.mock('@store/ephemeral_store', () => ({
|
||||
getNavigationTopComponentId: jest.fn(),
|
||||
clearNavigationComponents: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
const store = mockStore(intitialState);
|
||||
Store.redux = store;
|
||||
|
||||
describe('@actions/navigation', () => {
|
||||
const topComponentId = 'top-component-id';
|
||||
const name = 'name';
|
||||
const title = 'title';
|
||||
const theme = Preferences.THEMES.default;
|
||||
const passProps = {
|
||||
testProp: 'prop',
|
||||
};
|
||||
const options = {
|
||||
testOption: 'test',
|
||||
};
|
||||
EphemeralStore.getNavigationTopComponentId.mockReturnValue(topComponentId);
|
||||
|
||||
test('resetToChannel should call Navigation.setRoot', () => {
|
||||
const setRoot = jest.spyOn(Navigation, 'setRoot');
|
||||
|
||||
const expectedLayout = {
|
||||
root: {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: 'Channel',
|
||||
name: 'Channel',
|
||||
passProps,
|
||||
options: {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
backButton: {
|
||||
visible: false,
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
NavigationActions.resetToChannel(passProps);
|
||||
expect(setRoot).toHaveBeenCalledWith(expectedLayout);
|
||||
});
|
||||
|
||||
test('resetToSelectServer should call Navigation.setRoot', () => {
|
||||
const setRoot = jest.spyOn(Navigation, 'setRoot');
|
||||
|
||||
const allowOtherServers = false;
|
||||
const expectedLayout = {
|
||||
root: {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: 'SelectServer',
|
||||
name: 'SelectServer',
|
||||
passProps: {
|
||||
allowOtherServers,
|
||||
},
|
||||
options: {
|
||||
layout: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
visible: false,
|
||||
height: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
NavigationActions.resetToSelectServer(allowOtherServers);
|
||||
expect(setRoot).toHaveBeenCalledWith(expectedLayout);
|
||||
});
|
||||
|
||||
test('resetToTeams should call Navigation.setRoot', () => {
|
||||
const setRoot = jest.spyOn(Navigation, 'setRoot');
|
||||
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
visible: true,
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: title,
|
||||
},
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const expectedLayout = {
|
||||
root: {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
NavigationActions.resetToTeams(name, title, passProps, options);
|
||||
expect(setRoot).toHaveBeenCalledWith(expectedLayout);
|
||||
});
|
||||
|
||||
test('goToScreen should call Navigation.push', () => {
|
||||
const push = jest.spyOn(Navigation, 'push');
|
||||
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
popGesture: true,
|
||||
sideMenu: {
|
||||
left: {enabled: false},
|
||||
right: {enabled: false},
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
visible: true,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: title,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const expectedLayout = {
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
};
|
||||
|
||||
NavigationActions.goToScreen(name, title, passProps, options);
|
||||
expect(push).toHaveBeenCalledWith(topComponentId, expectedLayout);
|
||||
});
|
||||
|
||||
test('popTopScreen should call Navigation.pop', () => {
|
||||
const pop = jest.spyOn(Navigation, 'pop');
|
||||
|
||||
NavigationActions.popTopScreen();
|
||||
expect(pop).toHaveBeenCalledWith(topComponentId);
|
||||
|
||||
const otherComponentId = `other-${topComponentId}`;
|
||||
NavigationActions.popTopScreen(otherComponentId);
|
||||
expect(pop).toHaveBeenCalledWith(otherComponentId);
|
||||
});
|
||||
|
||||
test('popToRoot should call Navigation.popToRoot', async () => {
|
||||
const popToRoot = jest.spyOn(Navigation, 'popToRoot');
|
||||
|
||||
await NavigationActions.popToRoot();
|
||||
expect(popToRoot).toHaveBeenCalledWith(topComponentId);
|
||||
});
|
||||
|
||||
test('showModal should call Navigation.showModal', () => {
|
||||
const showModal = jest.spyOn(Navigation, 'showModal');
|
||||
|
||||
const defaultOptions = {
|
||||
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
visible: true,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: title,
|
||||
},
|
||||
leftButtonColor: theme.sidebarHeaderTextColor,
|
||||
rightButtonColor: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedLayout = {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
NavigationActions.showModal(name, title, passProps, options);
|
||||
expect(showModal).toHaveBeenCalledWith(expectedLayout);
|
||||
});
|
||||
|
||||
test('showModalOverCurrentContext should call Navigation.showModal', () => {
|
||||
const showModal = jest.spyOn(Navigation, 'showModal');
|
||||
|
||||
const animationsEnabled = (Platform.OS === 'android').toString();
|
||||
const showModalOverCurrentContextTitle = '';
|
||||
const showModalOverCurrentContextOptions = {
|
||||
modalPresentationStyle: 'overCurrentContext',
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
componentBackgroundColor: 'transparent',
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
},
|
||||
animations: {
|
||||
showModal: {
|
||||
waitForRender: true,
|
||||
enabled: animationsEnabled,
|
||||
alpha: {
|
||||
from: 0,
|
||||
to: 1,
|
||||
duration: 250,
|
||||
},
|
||||
},
|
||||
dismissModal: {
|
||||
enabled: animationsEnabled,
|
||||
alpha: {
|
||||
from: 1,
|
||||
to: 0,
|
||||
duration: 250,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const showModalOptions = {
|
||||
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
visible: true,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: showModalOverCurrentContextTitle,
|
||||
},
|
||||
leftButtonColor: theme.sidebarHeaderTextColor,
|
||||
rightButtonColor: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
};
|
||||
const defaultOptions = merge(showModalOverCurrentContextOptions, options);
|
||||
|
||||
const expectedLayout = {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(showModalOptions, defaultOptions),
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
NavigationActions.showModalOverCurrentContext(name, passProps, options);
|
||||
expect(showModal).toHaveBeenCalledWith(expectedLayout);
|
||||
});
|
||||
|
||||
test('showSearchModal should call Navigation.showModal', () => {
|
||||
const showModal = jest.spyOn(Navigation, 'showModal');
|
||||
|
||||
const showSearchModalName = 'Search';
|
||||
const showSearchModalTitle = '';
|
||||
const initialValue = 'initial-value';
|
||||
const showSearchModalPassProps = {initialValue};
|
||||
const showSearchModalOptions = {
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
},
|
||||
};
|
||||
const defaultOptions = {
|
||||
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
visible: true,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: showSearchModalTitle,
|
||||
},
|
||||
leftButtonColor: theme.sidebarHeaderTextColor,
|
||||
rightButtonColor: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedLayout = {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: showSearchModalName,
|
||||
name: showSearchModalName,
|
||||
passProps: showSearchModalPassProps,
|
||||
options: merge(defaultOptions, showSearchModalOptions),
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
NavigationActions.showSearchModal(initialValue);
|
||||
expect(showModal).toHaveBeenCalledWith(expectedLayout);
|
||||
});
|
||||
|
||||
test('dismissModal should call Navigation.dismissModal', async () => {
|
||||
const dismissModal = jest.spyOn(Navigation, 'dismissModal');
|
||||
|
||||
await NavigationActions.dismissModal(options);
|
||||
expect(dismissModal).toHaveBeenCalledWith(topComponentId, options);
|
||||
});
|
||||
|
||||
test('dismissAllModals should call Navigation.dismissAllModals', async () => {
|
||||
const dismissAllModals = jest.spyOn(Navigation, 'dismissAllModals');
|
||||
|
||||
await NavigationActions.dismissAllModals(options);
|
||||
expect(dismissAllModals).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
test('mergeNavigationOptions should call Navigation.mergeOptions', () => {
|
||||
const mergeOptions = jest.spyOn(Navigation, 'mergeOptions');
|
||||
|
||||
NavigationActions.mergeNavigationOptions(topComponentId, options);
|
||||
expect(mergeOptions).toHaveBeenCalledWith(topComponentId, options);
|
||||
});
|
||||
|
||||
test('setButtons should call Navigation.mergeOptions', () => {
|
||||
const mergeOptions = jest.spyOn(Navigation, 'mergeOptions');
|
||||
|
||||
const buttons = {
|
||||
leftButtons: ['left-button'],
|
||||
rightButtons: ['right-button'],
|
||||
};
|
||||
const setButtonsOptions = {
|
||||
topBar: {
|
||||
...buttons,
|
||||
},
|
||||
};
|
||||
|
||||
NavigationActions.setButtons(topComponentId, buttons);
|
||||
expect(mergeOptions).toHaveBeenCalledWith(topComponentId, setButtonsOptions);
|
||||
});
|
||||
|
||||
test('showOverlay should call Navigation.showOverlay', () => {
|
||||
const showOverlay = jest.spyOn(Navigation, 'showOverlay');
|
||||
|
||||
const defaultOptions = {
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
componentBackgroundColor: 'transparent',
|
||||
},
|
||||
overlay: {
|
||||
interceptTouchOutside: false,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedLayout = {
|
||||
component: {
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
};
|
||||
|
||||
NavigationActions.showOverlay(name, passProps, options);
|
||||
expect(showOverlay).toHaveBeenCalledWith(expectedLayout);
|
||||
});
|
||||
|
||||
test('dismissOverlay should call Navigation.dismissOverlay', async () => {
|
||||
const dismissOverlay = jest.spyOn(Navigation, 'dismissOverlay');
|
||||
|
||||
await NavigationActions.dismissOverlay(topComponentId);
|
||||
expect(dismissOverlay).toHaveBeenCalledWith(topComponentId);
|
||||
});
|
||||
});
|
||||
@@ -5,109 +5,253 @@ import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
import {ChannelTypes, RoleTypes} from '@mm-redux/action_types';
|
||||
import {UserTypes} from 'mattermost-redux/action_types';
|
||||
import {
|
||||
fetchMyChannelsAndMembers,
|
||||
getChannelByNameAndTeamName,
|
||||
leaveChannel as serviceLeaveChannel,
|
||||
} from '@mm-redux/actions/channels';
|
||||
import {getFilesForPost} from '@mm-redux/actions/files';
|
||||
import {savePreferences} from '@mm-redux/actions/preferences';
|
||||
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';
|
||||
markChannelAsRead,
|
||||
leaveChannel as serviceLeaveChannel, markChannelAsViewed,
|
||||
selectChannel,
|
||||
} from 'mattermost-redux/actions/channels';
|
||||
import {
|
||||
getPosts,
|
||||
getPostsBefore,
|
||||
getPostsSince,
|
||||
getPostThread,
|
||||
} from 'mattermost-redux/actions/posts';
|
||||
import {getFilesForPost} from 'mattermost-redux/actions/files';
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {getTeamMembersByIds, selectTeam} from 'mattermost-redux/actions/teams';
|
||||
import {getProfilesInChannel} from 'mattermost-redux/actions/users';
|
||||
import {General, Preferences} from 'mattermost-redux/constants';
|
||||
import {getPostIdsInChannel} from 'mattermost-redux/selectors/entities/posts';
|
||||
import {
|
||||
getChannel,
|
||||
getCurrentChannelId,
|
||||
getMyChannelMember,
|
||||
getRedirectChannelNameForTeam,
|
||||
getChannelsNameMapInTeam,
|
||||
isManuallyUnread,
|
||||
} from '@mm-redux/selectors/entities/channels';
|
||||
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
|
||||
import {getTeamByName} from '@mm-redux/selectors/entities/teams';
|
||||
} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentTeamId, getTeamByName} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
import {getChannelByName as selectChannelByName, getChannelsIdForTeam} from '@mm-redux/utils/channel_utils';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import telemetry from 'app/telemetry';
|
||||
|
||||
import {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';
|
||||
import {isDirectChannelVisible, isGroupChannelVisible, getChannelSinceValue} from '@utils/channels';
|
||||
import {isPendingPost} from '@utils/general';
|
||||
import {
|
||||
getChannelByName,
|
||||
getDirectChannelName,
|
||||
getUserIdFromChannelName,
|
||||
isDirectChannel,
|
||||
isGroupChannel,
|
||||
getChannelByName as getChannelByNameSelector,
|
||||
} from 'mattermost-redux/utils/channel_utils';
|
||||
import EventEmitter from 'mattermost-redux/utils/event_emitter';
|
||||
import {getLastCreateAt} from 'mattermost-redux/utils/post_utils';
|
||||
import {getPreferencesByCategory} from 'mattermost-redux/utils/preference_utils';
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from 'app/constants/post_textbox';
|
||||
import {isDirectChannelVisible, isGroupChannelVisible} from 'app/utils/channels';
|
||||
|
||||
export function loadChannelsByTeamName(teamName, errorHandler) {
|
||||
const MAX_POST_TRIES = 3;
|
||||
|
||||
export function loadChannelsIfNecessary(teamId) {
|
||||
return async (dispatch) => {
|
||||
await dispatch(fetchMyChannelsAndMembers(teamId));
|
||||
};
|
||||
}
|
||||
|
||||
export function loadChannelsByTeamName(teamName) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {currentTeamId} = state.entities.teams;
|
||||
const team = getTeamByName(state, teamName);
|
||||
|
||||
if (!team && errorHandler) {
|
||||
errorHandler();
|
||||
}
|
||||
|
||||
if (team && team.id !== currentTeamId) {
|
||||
await dispatch(fetchMyChannelsAndMembers(team.id));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {currentUserId, profilesInChannel} = state.entities.users;
|
||||
const {channels, myMembers} = state.entities.channels;
|
||||
const {myPreferences} = state.entities.preferences;
|
||||
const {membersInTeam} = state.entities.teams;
|
||||
const dmPrefs = getPreferencesByCategory(myPreferences, Preferences.CATEGORY_DIRECT_CHANNEL_SHOW);
|
||||
const gmPrefs = getPreferencesByCategory(myPreferences, Preferences.CATEGORY_GROUP_CHANNEL_SHOW);
|
||||
const members = [];
|
||||
const loadProfilesForChannels = [];
|
||||
const prefs = [];
|
||||
|
||||
function buildPref(name) {
|
||||
return {
|
||||
user_id: currentUserId,
|
||||
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
|
||||
name,
|
||||
value: 'true',
|
||||
};
|
||||
}
|
||||
|
||||
// Find DM's and GM's that need to be shown
|
||||
const directChannels = Object.values(channels).filter((c) => (isDirectChannel(c) || isGroupChannel(c)));
|
||||
directChannels.forEach((channel) => {
|
||||
const member = myMembers[channel.id];
|
||||
if (isDirectChannel(channel) && !isDirectChannelVisible(currentUserId, myPreferences, channel) && member.mention_count > 0) {
|
||||
const teammateId = getUserIdFromChannelName(currentUserId, channel.name);
|
||||
let pref = dmPrefs.get(teammateId);
|
||||
if (pref) {
|
||||
pref = {...pref, value: 'true'};
|
||||
} else {
|
||||
pref = buildPref(teammateId);
|
||||
}
|
||||
dmPrefs.set(teammateId, pref);
|
||||
prefs.push(pref);
|
||||
} else if (isGroupChannel(channel) && !isGroupChannelVisible(myPreferences, channel) && (member.mention_count > 0 || member.msg_count < channel.total_msg_count)) {
|
||||
const id = channel.id;
|
||||
let pref = gmPrefs.get(id);
|
||||
if (pref) {
|
||||
pref = {...pref, value: 'true'};
|
||||
} else {
|
||||
pref = buildPref(id);
|
||||
}
|
||||
gmPrefs.set(id, pref);
|
||||
prefs.push(pref);
|
||||
}
|
||||
});
|
||||
|
||||
if (prefs.length) {
|
||||
savePreferences(currentUserId, prefs)(dispatch, getState);
|
||||
}
|
||||
|
||||
for (const [key, pref] of dmPrefs) {
|
||||
if (pref.value === 'true') {
|
||||
members.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, pref] of gmPrefs) {
|
||||
//only load the profiles in channels if we don't already have them
|
||||
if (pref.value === 'true' && !profilesInChannel[key]) {
|
||||
loadProfilesForChannels.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadProfilesForChannels.length) {
|
||||
for (let i = 0; i < loadProfilesForChannels.length; i++) {
|
||||
const channelId = loadProfilesForChannels[i];
|
||||
getProfilesInChannel(channelId, 0)(dispatch, getState);
|
||||
}
|
||||
}
|
||||
|
||||
let membersToLoad = members;
|
||||
if (membersInTeam[teamId]) {
|
||||
membersToLoad = members.filter((m) => !membersInTeam[teamId].hasOwnProperty(m));
|
||||
}
|
||||
|
||||
if (membersToLoad.length) {
|
||||
getTeamMembersByIds(teamId, membersToLoad)(dispatch, getState);
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
for (let i = 0; i < members.length; i++) {
|
||||
const channelName = getDirectChannelName(currentUserId, members[i]);
|
||||
const channel = getChannelByName(channels, channelName);
|
||||
if (channel) {
|
||||
actions.push({
|
||||
type: UserTypes.RECEIVED_PROFILE_IN_CHANNEL,
|
||||
data: {id: channel.id, user_id: members[i]},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (actions.length) {
|
||||
dispatch(batchActions(actions), getState);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPostsIfNecessaryWithRetry(channelId) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const postIds = getPostIdsInChannel(state, channelId);
|
||||
const {posts} = state.entities.posts;
|
||||
const postsIds = getPostIdsInChannel(state, channelId);
|
||||
const actions = [];
|
||||
|
||||
const time = Date.now();
|
||||
|
||||
let loadMorePostsVisible = true;
|
||||
let postAction;
|
||||
if (!postIds || postIds.length < ViewTypes.POST_VISIBILITY_CHUNK_SIZE) {
|
||||
let received;
|
||||
if (!postsIds || postsIds.length < ViewTypes.POST_VISIBILITY_CHUNK_SIZE) {
|
||||
// Get the first page of posts if it appears we haven't gotten it yet, like the webapp
|
||||
postAction = getPosts(channelId);
|
||||
} else {
|
||||
const since = getChannelSinceValue(state, channelId, postIds);
|
||||
postAction = getPostsSince(channelId, since);
|
||||
}
|
||||
received = await retryGetPostsAction(getPosts(channelId), dispatch, getState);
|
||||
|
||||
const received = await dispatch(fetchPostActionWithRetry(postAction));
|
||||
if (received?.order) {
|
||||
const count = received.order.length;
|
||||
loadMorePostsVisible = count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
actions.push({
|
||||
type: ViewTypes.SET_INITIAL_POST_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
count,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const lastConnectAt = state.websocket?.lastConnectAt || 0;
|
||||
const lastGetPosts = state.views.channel.lastGetPosts[channelId];
|
||||
|
||||
let since;
|
||||
if (lastGetPosts && lastGetPosts < lastConnectAt) {
|
||||
// Since the websocket disconnected, we may have missed some posts since then
|
||||
since = lastGetPosts;
|
||||
} else {
|
||||
// Trust that we've received all posts since the last time the websocket disconnected
|
||||
// so just get any that have changed since the latest one we've received
|
||||
const postsForChannel = postsIds.map((id) => posts[id]);
|
||||
since = getLastCreateAt(postsForChannel);
|
||||
}
|
||||
|
||||
received = await retryGetPostsAction(getPostsSince(channelId, since), dispatch, getState);
|
||||
|
||||
if (received?.order) {
|
||||
const count = received.order.length;
|
||||
loadMorePostsVisible = postsIds.length + count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
actions.push({
|
||||
type: ViewTypes.SET_INITIAL_POST_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
count: postsIds.length + count,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (received) {
|
||||
actions.push({
|
||||
type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
|
||||
channelId,
|
||||
time,
|
||||
},
|
||||
setChannelRetryFailed(false));
|
||||
|
||||
if (received?.order) {
|
||||
const count = received.order.length;
|
||||
loadMorePostsVisible = count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
actions.push(setLoadMorePostsVisible(loadMorePostsVisible));
|
||||
dispatch(batchActions(actions, 'BATCH_LOAD_POSTS_IN_CHANNEL'));
|
||||
dispatch(batchActions(actions));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchPostActionWithRetry(action, maxTries = MAX_RETRIES) {
|
||||
return async (dispatch) => {
|
||||
for (let i = 0; i <= maxTries; i++) {
|
||||
const {data} = await dispatch(action); // eslint-disable-line no-await-in-loop
|
||||
export async function retryGetPostsAction(action, dispatch, getState, maxTries = MAX_POST_TRIES) {
|
||||
for (let i = 0; i < maxTries; i++) {
|
||||
const {data} = await dispatch(action); // eslint-disable-line no-await-in-loop
|
||||
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
if (data) {
|
||||
dispatch(setChannelRetryFailed(false));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(setChannelRetryFailed(true));
|
||||
|
||||
return null;
|
||||
};
|
||||
dispatch(setChannelRetryFailed(true));
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadFilesForPostIfNecessary(postId) {
|
||||
@@ -184,6 +328,7 @@ export function selectPenultimateChannel(teamId) {
|
||||
lastChannel.delete_at === 0 &&
|
||||
(lastChannel.team_id === teamId || isDMVisible || isGMVisible)
|
||||
) {
|
||||
dispatch(setChannelLoading(true));
|
||||
dispatch(handleSelectChannel(lastChannelId));
|
||||
return;
|
||||
}
|
||||
@@ -197,7 +342,8 @@ export function selectDefaultChannel(teamId) {
|
||||
const state = getState();
|
||||
|
||||
const channelsInTeam = getChannelsNameMapInTeam(state, teamId);
|
||||
const channel = selectChannelByName(channelsInTeam, getRedirectChannelNameForTeam(state, teamId));
|
||||
const channel = getChannelByNameSelector(channelsInTeam, getRedirectChannelNameForTeam(state, teamId));
|
||||
|
||||
let channelId;
|
||||
if (channel) {
|
||||
channelId = channel.id;
|
||||
@@ -205,8 +351,9 @@ export function selectDefaultChannel(teamId) {
|
||||
// Handle case when the default channel cannot be found
|
||||
// so we need to get the first available channel of the team
|
||||
const channels = Object.values(channelsInTeam);
|
||||
const firstChannel = channels.length ? channels[0].id : '';
|
||||
channelId = firstChannel;
|
||||
const firstChannel = channels.length ? channels[0].id : {id: ''};
|
||||
|
||||
channelId = firstChannel.id;
|
||||
}
|
||||
|
||||
if (channelId) {
|
||||
@@ -215,60 +362,71 @@ export function selectDefaultChannel(teamId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function handleSelectChannel(channelId) {
|
||||
export function handleSelectChannel(channelId, fromPushNotification = false) {
|
||||
return async (dispatch, getState) => {
|
||||
const dt = Date.now();
|
||||
const state = getState();
|
||||
const {channels, currentChannelId, myMembers} = state.entities.channels;
|
||||
const {currentTeamId} = state.entities.teams;
|
||||
const channel = channels[channelId];
|
||||
const member = myMembers[channelId];
|
||||
const channel = getChannel(state, channelId);
|
||||
const currentTeamId = getCurrentTeamId(state);
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
const sameChannel = channelId === currentChannelId;
|
||||
const member = getMyChannelMember(state, channelId);
|
||||
|
||||
if (channel) {
|
||||
dispatch(setLoadMorePostsVisible(true));
|
||||
|
||||
// If the app is open from push notification, we already fetched the posts.
|
||||
if (!fromPushNotification) {
|
||||
dispatch(loadPostsIfNecessaryWithRetry(channelId));
|
||||
|
||||
let previousChannelId = null;
|
||||
if (currentChannelId !== channelId) {
|
||||
previousChannelId = currentChannelId;
|
||||
}
|
||||
|
||||
const actions = markAsViewedAndReadBatch(state, channelId, previousChannelId);
|
||||
actions.push({
|
||||
type: ChannelTypes.SELECT_CHANNEL,
|
||||
data: channelId,
|
||||
extra: {
|
||||
channel,
|
||||
member,
|
||||
teamId: channel.team_id || currentTeamId,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_SWITCH_CHANNEL'));
|
||||
|
||||
console.log('channel switch to', channel?.display_name, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
|
||||
}
|
||||
|
||||
const actions = [
|
||||
selectChannel(channelId),
|
||||
setChannelDisplayName(channel.display_name),
|
||||
{
|
||||
type: ViewTypes.SET_INITIAL_POST_VISIBILITY,
|
||||
data: channelId,
|
||||
},
|
||||
setChannelLoading(false),
|
||||
{
|
||||
type: ViewTypes.SET_LAST_CHANNEL_FOR_TEAM,
|
||||
teamId: currentTeamId,
|
||||
channelId,
|
||||
},
|
||||
];
|
||||
|
||||
let markPreviousChannelId;
|
||||
if (!fromPushNotification && !sameChannel) {
|
||||
markPreviousChannelId = currentChannelId;
|
||||
actions.push({
|
||||
type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER,
|
||||
data: currentChannelId,
|
||||
channel: getChannel(state, currentChannelId),
|
||||
member: getMyChannelMember(state, currentChannelId),
|
||||
});
|
||||
}
|
||||
|
||||
if (!fromPushNotification) {
|
||||
actions.push({
|
||||
type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER,
|
||||
data: channelId,
|
||||
channel,
|
||||
member,
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions));
|
||||
|
||||
dispatch(markChannelViewedAndRead(channelId, markPreviousChannelId));
|
||||
};
|
||||
}
|
||||
|
||||
export function handleSelectChannelByName(channelName, teamName, errorHandler) {
|
||||
export function handleSelectChannelByName(channelName, teamName) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {teams: currentTeams, currentTeamId} = state.entities.teams;
|
||||
const currentTeam = currentTeams[currentTeamId];
|
||||
const currentTeamName = currentTeam?.name;
|
||||
const response = await dispatch(getChannelByNameAndTeamName(teamName || currentTeamName, channelName));
|
||||
const {error, data: channel} = response;
|
||||
const {data: channel} = await dispatch(getChannelByNameAndTeamName(teamName || currentTeamName, channelName));
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
const reachable = getChannelReachable(state, channelName, teamName);
|
||||
|
||||
if (!reachable && errorHandler) {
|
||||
errorHandler();
|
||||
}
|
||||
|
||||
// Fallback to API response error, if any.
|
||||
if (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
if (teamName && teamName !== currentTeamName) {
|
||||
const team = getTeamByName(state, teamName);
|
||||
@@ -278,22 +436,16 @@ export function handleSelectChannelByName(channelName, teamName, errorHandler) {
|
||||
if (channel && currentChannelId !== channel.id) {
|
||||
dispatch(handleSelectChannel(channel.id));
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export function handlePostDraftChanged(channelId, draft) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
if (state.views.channel.drafts[channelId]?.draft !== draft) {
|
||||
dispatch({
|
||||
type: ViewTypes.POST_DRAFT_CHANGED,
|
||||
channelId,
|
||||
draft,
|
||||
});
|
||||
}
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ViewTypes.POST_DRAFT_CHANGED,
|
||||
channelId,
|
||||
draft,
|
||||
}, getState);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,98 +461,9 @@ export function insertToDraft(value) {
|
||||
}
|
||||
|
||||
export function markChannelViewedAndRead(channelId, previousChannelId, markOnServer = true) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const actions = markAsViewedAndReadBatch(state, channelId, previousChannelId, markOnServer);
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_MARK_CHANNEL_VIEWED_AND_READ'));
|
||||
};
|
||||
}
|
||||
|
||||
export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', markOnServer = true) {
|
||||
const actions = [];
|
||||
const {channels, myMembers} = state.entities.channels;
|
||||
const channel = channels[channelId];
|
||||
const member = myMembers[channelId];
|
||||
const prevMember = myMembers[prevChannelId];
|
||||
const prevChanManuallyUnread = isManuallyUnread(state, prevChannelId);
|
||||
const prevChannel = (!prevChanManuallyUnread && prevChannelId) ? channels[prevChannelId] : null; // May be null since prevChannelId is optional
|
||||
|
||||
if (markOnServer) {
|
||||
Client4.viewMyChannel(channelId, prevChanManuallyUnread ? '' : prevChannelId).catch(() => {
|
||||
// do nothing just adding the handler to avoid the warning
|
||||
});
|
||||
}
|
||||
|
||||
if (member) {
|
||||
actions.push({
|
||||
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
|
||||
data: {...member, last_viewed_at: Date.now()},
|
||||
});
|
||||
|
||||
if (isManuallyUnread(state, channelId)) {
|
||||
actions.push({
|
||||
type: ChannelTypes.REMOVE_MANUALLY_UNREAD,
|
||||
data: {channelId},
|
||||
});
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
actions.push({
|
||||
type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT,
|
||||
data: {
|
||||
teamId: channel.team_id,
|
||||
channelId,
|
||||
amount: channel.total_msg_count - member.msg_count,
|
||||
},
|
||||
}, {
|
||||
type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT,
|
||||
data: {
|
||||
teamId: channel.team_id,
|
||||
channelId,
|
||||
amount: member.mention_count,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (prevMember) {
|
||||
if (!prevChanManuallyUnread) {
|
||||
actions.push({
|
||||
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
|
||||
data: {...prevMember, last_viewed_at: Date.now()},
|
||||
});
|
||||
}
|
||||
|
||||
if (prevChannel) {
|
||||
actions.push({
|
||||
type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT,
|
||||
data: {
|
||||
teamId: prevChannel.team_id,
|
||||
channelId: prevChannelId,
|
||||
amount: prevChannel.total_msg_count - prevMember.msg_count,
|
||||
},
|
||||
}, {
|
||||
type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT,
|
||||
data: {
|
||||
teamId: prevChannel.team_id,
|
||||
channelId: prevChannelId,
|
||||
amount: prevMember.mention_count,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function markChannelViewedAndReadOnReconnect(channelId) {
|
||||
return (dispatch, getState) => {
|
||||
if (isManuallyUnread(getState(), channelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(markChannelViewedAndRead(channelId));
|
||||
return (dispatch) => {
|
||||
dispatch(markChannelAsRead(channelId, previousChannelId, markOnServer));
|
||||
dispatch(markChannelAsViewed(channelId, previousChannelId));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -466,16 +529,10 @@ export function closeGMChannel(channel) {
|
||||
}
|
||||
|
||||
export function refreshChannelWithRetry(channelId) {
|
||||
return async (dispatch) => {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch(setChannelRefreshing(true));
|
||||
const posts = await dispatch(fetchPostActionWithRetry(getPosts(channelId)));
|
||||
const actions = [setChannelRefreshing(false)];
|
||||
|
||||
if (posts) {
|
||||
actions.push(setChannelRetryFailed(false));
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_REEFRESH_CHANNEL'));
|
||||
const posts = await retryGetPostsAction(getPosts(channelId), dispatch, getState);
|
||||
dispatch(setChannelRefreshing(false));
|
||||
return posts;
|
||||
};
|
||||
}
|
||||
@@ -540,8 +597,8 @@ export function setChannelDisplayName(displayName) {
|
||||
export function increasePostVisibility(channelId, postId) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {loadingPosts} = state.views.channel;
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const {loadingPosts, postVisibility} = state.views.channel;
|
||||
const currentPostVisibility = postVisibility[channelId] || 0;
|
||||
|
||||
if (loadingPosts[channelId]) {
|
||||
return true;
|
||||
@@ -552,8 +609,17 @@ export function increasePostVisibility(channelId, postId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPendingPost(postId, currentUserId)) {
|
||||
// This is the first created post in the channel
|
||||
// Check if we already have the posts that we want to show
|
||||
const loadedPostCount = state.views.channel.postCountInChannel[channelId] || 0;
|
||||
const desiredPostVisibility = currentPostVisibility + ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
|
||||
if (loadedPostCount >= desiredPostVisibility) {
|
||||
// We already have the posts, so we just need to show them
|
||||
dispatch(batchActions([
|
||||
doIncreasePostVisibility(channelId),
|
||||
setLoadMorePostsVisible(true),
|
||||
]));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -568,8 +634,7 @@ export function increasePostVisibility(channelId, postId) {
|
||||
|
||||
const pageSize = ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
|
||||
const postAction = getPostsBefore(channelId, postId, 0, pageSize);
|
||||
const result = await dispatch(fetchPostActionWithRetry(postAction));
|
||||
const result = await retryGetPostsAction(getPostsBefore(channelId, postId, 0, pageSize), dispatch, getState);
|
||||
|
||||
const actions = [{
|
||||
type: ViewTypes.LOADING_POSTS,
|
||||
@@ -577,19 +642,27 @@ export function increasePostVisibility(channelId, postId) {
|
||||
channelId,
|
||||
}];
|
||||
|
||||
if (result) {
|
||||
actions.push(setChannelRetryFailed(false));
|
||||
}
|
||||
|
||||
let hasMorePost = false;
|
||||
if (result?.order) {
|
||||
const count = result.order.length;
|
||||
hasMorePost = count >= pageSize;
|
||||
|
||||
actions.push({
|
||||
type: ViewTypes.INCREASE_POST_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
count,
|
||||
},
|
||||
});
|
||||
|
||||
// make sure to increment the posts visibility
|
||||
// only if we got results
|
||||
actions.push(doIncreasePostVisibility(channelId));
|
||||
|
||||
actions.push(setLoadMorePostsVisible(hasMorePost));
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_LOAD_MORE_POSTS'));
|
||||
dispatch(batchActions(actions));
|
||||
telemetry.end(['posts:loading']);
|
||||
telemetry.save();
|
||||
|
||||
@@ -597,97 +670,17 @@ export function increasePostVisibility(channelId, postId) {
|
||||
};
|
||||
}
|
||||
|
||||
function doIncreasePostVisibility(channelId) {
|
||||
return {
|
||||
type: ViewTypes.INCREASE_POST_VISIBILITY,
|
||||
data: channelId,
|
||||
amount: ViewTypes.POST_VISIBILITY_CHUNK_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function setLoadMorePostsVisible(visible) {
|
||||
return {
|
||||
type: ViewTypes.SET_LOAD_MORE_POSTS_VISIBLE,
|
||||
data: visible,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadChannelsForTeam(teamId, skipDispatch = false) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const data = {
|
||||
sync: true,
|
||||
teamId,
|
||||
teamChannels: getChannelsIdForTeam(state, teamId),
|
||||
};
|
||||
|
||||
const actions = [];
|
||||
|
||||
if (currentUserId) {
|
||||
for (let i = 0; i <= MAX_RETRIES; i++) {
|
||||
try {
|
||||
console.log('Fetching channels attempt', teamId, (i + 1)); //eslint-disable-line no-console
|
||||
const [channels, channelMembers] = await Promise.all([ //eslint-disable-line no-await-in-loop
|
||||
Client4.getMyChannels(teamId, true),
|
||||
Client4.getMyChannelMembers(teamId),
|
||||
]);
|
||||
|
||||
data.channels = channels;
|
||||
data.channelMembers = channelMembers;
|
||||
break;
|
||||
} catch (err) {
|
||||
if (i === MAX_RETRIES) {
|
||||
const hasChannelsLoaded = state.entities.channels.channelsInTeam[teamId]?.size > 0;
|
||||
return {error: hasChannelsLoaded ? null : err};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.channels) {
|
||||
actions.push({
|
||||
type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS,
|
||||
data,
|
||||
});
|
||||
|
||||
if (!skipDispatch) {
|
||||
const rolesToLoad = new Set();
|
||||
const members = data.channelMembers;
|
||||
for (const member of members) {
|
||||
for (const role of member.roles.split(' ')) {
|
||||
rolesToLoad.add(role);
|
||||
}
|
||||
}
|
||||
|
||||
if (rolesToLoad.size > 0) {
|
||||
try {
|
||||
data.roles = await Client4.getRolesByNames(Array.from(rolesToLoad));
|
||||
if (data.roles.length) {
|
||||
actions.push({
|
||||
type: RoleTypes.RECEIVED_ROLES,
|
||||
data: data.roles,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
//eslint-disable-next-line no-console
|
||||
console.log('Could not retrieve channel members roles for the user');
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_LOAD_CHANNELS_FOR_TEAM'));
|
||||
}
|
||||
|
||||
// Fetch needed profiles from channel creators and direct channels
|
||||
dispatch(loadSidebar(data));
|
||||
|
||||
dispatch(loadUnreadChannelPosts(data.channels, data.channelMembers));
|
||||
}
|
||||
}
|
||||
|
||||
return {data};
|
||||
};
|
||||
}
|
||||
|
||||
function loadSidebar(data) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {channels, channelMembers} = data;
|
||||
|
||||
const sidebarActions = await loadSidebarDirectMessagesProfiles(state, channels, channelMembers);
|
||||
if (sidebarActions.length) {
|
||||
dispatch(batchActions(sidebarActions, 'BATCH_LOAD_SIDEBAR'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,39 +4,21 @@
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import initialState from 'app/initial_state';
|
||||
import testHelper from 'test/test_helper';
|
||||
|
||||
import * as ChannelActions from '@actions/views/channel';
|
||||
import {ViewTypes} from '@constants';
|
||||
import {ChannelTypes} from '@mm-redux/action_types';
|
||||
import postReducer from '@mm-redux/reducers/entities/posts';
|
||||
import initialState from '@store/initial_state';
|
||||
|
||||
const {
|
||||
handleSelectChannel,
|
||||
import {
|
||||
handleSelectChannelByName,
|
||||
loadPostsIfNecessaryWithRetry,
|
||||
} = ChannelActions;
|
||||
} from 'app/actions/views/channel';
|
||||
|
||||
const MOCK_CHANNEL_MARK_AS_READ = 'MOCK_CHANNEL_MARK_AS_READ';
|
||||
const MOCK_CHANNEL_MARK_AS_VIEWED = 'MOCK_CHANNEL_MARK_AS_VIEWED';
|
||||
import postReducer from 'mattermost-redux/reducers/entities/posts';
|
||||
|
||||
jest.mock('@mm-redux/actions/channels', () => {
|
||||
const channelActions = jest.requireActual('../../mm-redux/actions/channels');
|
||||
return {
|
||||
...channelActions,
|
||||
markChannelAsRead: jest.fn().mockReturnValue({type: 'MOCK_CHANNEL_MARK_AS_READ'}),
|
||||
markChannelAsViewed: jest.fn().mockReturnValue({type: 'MOCK_CHANNEL_MARK_AS_VIEWED'}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@mm-redux/selectors/entities/teams', () => {
|
||||
const teamSelectors = jest.requireActual('../../mm-redux/selectors/entities/teams');
|
||||
return {
|
||||
...teamSelectors,
|
||||
getTeamByName: jest.fn(() => ({name: 'current-team-name'})),
|
||||
};
|
||||
});
|
||||
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
|
||||
getChannel: () => ({data: 'received-channel-id'}),
|
||||
getCurrentChannelId: () => 'current-channel-id',
|
||||
getMyChannelMember: () => ({data: {member: {}}}),
|
||||
}));
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
@@ -49,7 +31,7 @@ describe('Actions.Views.Channel', () => {
|
||||
const MOCK_RECEIVED_POSTS_IN_CHANNEL = 'RECEIVED_POSTS_IN_CHANNEL';
|
||||
const MOCK_RECEIVED_POSTS_SINCE = 'MOCK_RECEIVED_POSTS_SINCE';
|
||||
|
||||
const actions = require('@mm-redux/actions/channels');
|
||||
const actions = require('mattermost-redux/actions/channels');
|
||||
actions.getChannelByNameAndTeamName = jest.fn((teamName) => {
|
||||
if (teamName) {
|
||||
return {
|
||||
@@ -67,7 +49,7 @@ describe('Actions.Views.Channel', () => {
|
||||
type: MOCK_SELECT_CHANNEL_TYPE,
|
||||
data: 'selected-channel-id',
|
||||
});
|
||||
const postActions = require('./post');
|
||||
const postActions = require('mattermost-redux/actions/posts');
|
||||
postActions.getPostsSince = jest.fn(() => {
|
||||
return {
|
||||
type: MOCK_RECEIVED_POSTS_SINCE,
|
||||
@@ -97,7 +79,7 @@ describe('Actions.Views.Channel', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const postUtils = require('@mm-redux/utils/post_utils');
|
||||
const postUtils = require('mattermost-redux/utils/post_utils');
|
||||
postUtils.getLastCreateAt = jest.fn((array) => {
|
||||
return array[0].create_at;
|
||||
});
|
||||
@@ -117,36 +99,20 @@ describe('Actions.Views.Channel', () => {
|
||||
},
|
||||
channels: {
|
||||
currentChannelId,
|
||||
manuallyUnread: {},
|
||||
channels: {
|
||||
'channel-id': {id: 'channel-id', display_name: 'Test Channel'},
|
||||
'channel-id-2': {id: 'channel-id-2', display_name: 'Test Channel'},
|
||||
},
|
||||
myMembers: {
|
||||
'channel-id': {channel_id: 'channel-id', user_id: currentUserId, mention_count: 0, msg_count: 0},
|
||||
'channel-id-2': {channel_id: 'channel-id-2', user_id: currentUserId, mention_count: 0, msg_count: 0},
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
currentTeamId,
|
||||
teams: {
|
||||
[currentTeamId]: {
|
||||
id: currentTeamId,
|
||||
name: currentTeamName,
|
||||
currentTeamId,
|
||||
currentTeams: {
|
||||
[currentTeamId]: {
|
||||
name: currentTeamName,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const channelSelectors = require('@mm-redux/selectors/entities/channels');
|
||||
channelSelectors.getChannel = jest.fn((state, channelId) => ({data: channelId}));
|
||||
channelSelectors.getCurrentChannelId = jest.fn(() => currentChannelId);
|
||||
channelSelectors.getMyChannelMember = jest.fn(() => ({data: {member: {}}}));
|
||||
|
||||
const appChannelSelectors = require('app/selectors/channel');
|
||||
appChannelSelectors.getChannelReachable = jest.fn(() => true);
|
||||
|
||||
test('handleSelectChannelByName success', async () => {
|
||||
store = mockStore(storeObj);
|
||||
|
||||
@@ -156,14 +122,15 @@ describe('Actions.Views.Channel', () => {
|
||||
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
|
||||
expect(receivedChannel).toBe(true);
|
||||
|
||||
const selectedChannel = storeActions.some(({type}) => type === MOCK_RECEIVE_CHANNEL_TYPE);
|
||||
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCHING_REDUCER.BATCH');
|
||||
const selectedChannel = storeBatchActions[0].payload.some((action) => action.type === MOCK_SELECT_CHANNEL_TYPE);
|
||||
expect(selectedChannel).toBe(true);
|
||||
});
|
||||
|
||||
test('handleSelectChannelByName failure from null currentTeamName', async () => {
|
||||
const failStoreObj = {...storeObj};
|
||||
failStoreObj.entities.teams.currentTeamId = 'not-in-current-teams';
|
||||
store = mockStore(failStoreObj);
|
||||
failStoreObj.entities.teams.teams.currentTeamId = 'not-in-current-teams';
|
||||
store = mockStore(storeObj);
|
||||
|
||||
await store.dispatch(handleSelectChannelByName(currentChannelName, null));
|
||||
|
||||
@@ -175,36 +142,6 @@ describe('Actions.Views.Channel', () => {
|
||||
expect(storeBatchActions).toBe(false);
|
||||
});
|
||||
|
||||
test('handleSelectChannelByName failure from no permission to channel', async () => {
|
||||
store = mockStore({...storeObj});
|
||||
actions.getChannelByNameAndTeamName = jest.fn(() => {
|
||||
return {
|
||||
type: 'MOCK_ERROR',
|
||||
error: {
|
||||
message: "Can't get to channel.",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await store.dispatch(handleSelectChannelByName(currentChannelName, currentTeamName));
|
||||
|
||||
const storeActions = store.getActions();
|
||||
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
|
||||
expect(receivedChannel).toBe(false);
|
||||
});
|
||||
|
||||
test('handleSelectChannelByName failure from unreachable channel', async () => {
|
||||
appChannelSelectors.getChannelReachable = jest.fn(() => false);
|
||||
|
||||
store = mockStore(storeObj);
|
||||
|
||||
await store.dispatch(handleSelectChannelByName(currentChannelName, currentTeamName));
|
||||
|
||||
const storeActions = store.getActions();
|
||||
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
|
||||
expect(receivedChannel).toBe(false);
|
||||
});
|
||||
|
||||
test('loadPostsIfNecessaryWithRetry for the first time', async () => {
|
||||
store = mockStore(storeObj);
|
||||
|
||||
@@ -212,9 +149,9 @@ describe('Actions.Views.Channel', () => {
|
||||
expect(postActions.getPosts).toBeCalled();
|
||||
|
||||
const storeActions = store.getActions();
|
||||
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCH_LOAD_POSTS_IN_CHANNEL');
|
||||
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCHING_REDUCER.BATCH');
|
||||
const receivedPosts = storeActions.find(({type}) => type === MOCK_RECEIVED_POSTS);
|
||||
const receivedPostsAtAction = storeBatchActions[0].payload.some((action) => action.type === ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME);
|
||||
const receivedPostsAtAction = storeBatchActions[0].payload.some((action) => action.type === 'RECEIVED_POSTS_FOR_CHANNEL_AT_TIME');
|
||||
|
||||
nextPostState = postReducer(store.getState().entities.posts, receivedPosts);
|
||||
nextPostState = postReducer(nextPostState, {
|
||||
@@ -284,48 +221,4 @@ describe('Actions.Views.Channel', () => {
|
||||
expect(postActions.getPostsSince).toHaveBeenCalledWith(currentChannelId, store.getState().views.channel.lastGetPosts[currentChannelId]);
|
||||
expect(receivedPostsSince).not.toBe(null);
|
||||
});
|
||||
|
||||
const handleSelectChannelCases = [
|
||||
[currentChannelId],
|
||||
[`${currentChannelId}-2`],
|
||||
[`not-${currentChannelId}`],
|
||||
[`not-${currentChannelId}-2`],
|
||||
];
|
||||
test.each(handleSelectChannelCases)('handleSelectChannel dispatches selectChannelWithMember', async (channelId) => {
|
||||
const testObj = {...storeObj};
|
||||
testObj.entities.teams.currentTeamId = currentTeamId;
|
||||
store = mockStore(testObj);
|
||||
|
||||
await store.dispatch(handleSelectChannel(channelId));
|
||||
const storeActions = store.getActions();
|
||||
const storeBatchActions = storeActions.find(({type}) => type === 'BATCH_SWITCH_CHANNEL');
|
||||
const selectChannelWithMember = storeBatchActions?.payload.find(({type}) => type === ChannelTypes.SELECT_CHANNEL);
|
||||
const viewedAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_VIEWED);
|
||||
const readAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_READ);
|
||||
|
||||
const expectedSelectChannelWithMember = {
|
||||
type: ChannelTypes.SELECT_CHANNEL,
|
||||
data: channelId,
|
||||
extra: {
|
||||
channel: {
|
||||
id: channelId,
|
||||
display_name: 'Test Channel',
|
||||
},
|
||||
member: {
|
||||
channel_id: channelId,
|
||||
user_id: currentUserId,
|
||||
mention_count: 0,
|
||||
msg_count: 0,
|
||||
},
|
||||
teamId: currentTeamId,
|
||||
},
|
||||
};
|
||||
if (channelId.includes('not')) {
|
||||
expect(selectChannelWithMember).toBe(undefined);
|
||||
} else {
|
||||
expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember);
|
||||
}
|
||||
expect(viewedAction).not.toBe(null);
|
||||
expect(readAction).not.toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {addChannelMember} from '@mm-redux/actions/channels';
|
||||
import {addChannelMember} from 'mattermost-redux/actions/channels';
|
||||
|
||||
export function handleAddChannelMembers(channelId, members) {
|
||||
return async (dispatch) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const requests = members.map((m) => dispatch(addChannelMember(channelId, m)));
|
||||
const requests = members.map((m) => dispatch(addChannelMember(channelId, m, getState)));
|
||||
|
||||
return await Promise.all(requests);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {removeChannelMember} from '@mm-redux/actions/channels';
|
||||
import {removeChannelMember} from 'mattermost-redux/actions/channels';
|
||||
|
||||
export function handleRemoveChannelMembers(channelId, members) {
|
||||
return async (dispatch, getState) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {IntegrationTypes} from '@mm-redux/action_types';
|
||||
import {executeCommand as executeCommandService} from '@mm-redux/actions/integrations';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {IntegrationTypes} from 'mattermost-redux/action_types';
|
||||
import {executeCommand as executeCommandService} from 'mattermost-redux/actions/integrations';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
export function executeCommand(message, channelId, rootId) {
|
||||
return async (dispatch, getState) => {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {handleSelectChannel, setChannelDisplayName} from './channel';
|
||||
import {createChannel} from '@mm-redux/actions/channels';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
|
||||
import {cleanUpUrlable} from '@mm-redux/utils/channel_utils';
|
||||
import {generateId} from '@mm-redux/utils/helpers';
|
||||
import {createChannel} from 'mattermost-redux/actions/channels';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
import {cleanUpUrlable} from 'mattermost-redux/utils/channel_utils';
|
||||
import {generateId} from 'mattermost-redux/utils/helpers';
|
||||
|
||||
export function generateChannelNameFromDisplayName(displayName) {
|
||||
let name = cleanUpUrlable(displayName);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {updateMe, setDefaultProfileImage} from '@mm-redux/actions/users';
|
||||
import {updateMe, setDefaultProfileImage} from 'mattermost-redux/actions/users';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import {EmojiTypes} from '@mm-redux/action_types';
|
||||
import {addReaction as serviceAddReaction, getNeededCustomEmojis} from '@mm-redux/actions/posts';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {getPostIdsInCurrentChannel, makeGetPostIdsForThread} from '@mm-redux/selectors/entities/posts';
|
||||
import {addReaction as serviceAddReaction} from 'mattermost-redux/actions/posts';
|
||||
import {getPostIdsInCurrentChannel, makeGetPostIdsForThread} from 'mattermost-redux/selectors/entities/posts';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
@@ -46,55 +42,3 @@ export function incrementEmojiPickerPage() {
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function getEmojisInPosts(posts) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
// Do not wait for this as they need to be loaded one by one
|
||||
const emojisToLoad = getNeededCustomEmojis(state, posts);
|
||||
|
||||
if (emojisToLoad?.size > 0) {
|
||||
const promises = Array.from(emojisToLoad).map((name) => getCustomEmojiByName(name));
|
||||
const result = await Promise.all(promises);
|
||||
const actions = [];
|
||||
const data = [];
|
||||
|
||||
result.forEach((emoji, index) => {
|
||||
const name = emojisToLoad[index];
|
||||
|
||||
if (emoji) {
|
||||
switch (emoji) {
|
||||
case 404:
|
||||
actions.push({type: EmojiTypes.CUSTOM_EMOJI_DOES_NOT_EXIST, data: name});
|
||||
break;
|
||||
default:
|
||||
data.push(emoji);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (data.length) {
|
||||
actions.push({type: EmojiTypes.RECEIVED_CUSTOM_EMOJIS, data});
|
||||
}
|
||||
|
||||
if (actions.length) {
|
||||
dispatch(batchActions(actions, 'BATCH_GET_EMOJIS_FOR_POSTS'));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getCustomEmojiByName(name) {
|
||||
try {
|
||||
const data = await Client4.getCustomEmojiByName(name);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.status_code === 404) {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {FileTypes} from '@mm-redux/action_types';
|
||||
import {FileTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
import {buildFileUploadData, generateId} from 'app/utils/file';
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import {getDataRetentionPolicy} from '@mm-redux/actions/general';
|
||||
import {GeneralTypes} from '@mm-redux/action_types';
|
||||
import {getSessions} from '@mm-redux/actions/users';
|
||||
import {autoUpdateTimezone} from '@mm-redux/actions/timezone';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {getConfig, getLicense} from '@mm-redux/selectors/entities/general';
|
||||
import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone';
|
||||
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
|
||||
import {getDataRetentionPolicy} from 'mattermost-redux/actions/general';
|
||||
import {GeneralTypes} from 'mattermost-redux/action_types';
|
||||
import {getSessions} from 'mattermost-redux/actions/users';
|
||||
import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {isTimezoneEnabled} from 'mattermost-redux/selectors/entities/timezone';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
import {setAppCredentials} from 'app/init/credentials';
|
||||
import PushNotifications from 'app/push_notifications';
|
||||
import {getDeviceTimezone} from 'app/utils/timezone';
|
||||
import {setCSRFFromCookie} from 'app/utils/security';
|
||||
import {loadConfigAndLicense} from 'app/actions/views/root';
|
||||
|
||||
export function handleLoginIdChanged(loginId) {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ViewTypes.LOGIN_ID_CHANGED,
|
||||
loginId,
|
||||
}, getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function handlePasswordChanged(password) {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ViewTypes.PASSWORD_CHANGED,
|
||||
password,
|
||||
}, getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function handleSuccessfulLogin() {
|
||||
return async (dispatch, getState) => {
|
||||
await dispatch(loadConfigAndLicense());
|
||||
|
||||
const state = getState();
|
||||
const config = getConfig(state);
|
||||
const license = getLicense(state);
|
||||
@@ -35,8 +49,7 @@ export function handleSuccessfulLogin() {
|
||||
|
||||
const enableTimezone = isTimezoneEnabled(state);
|
||||
if (enableTimezone) {
|
||||
const timezone = getDeviceTimezone();
|
||||
dispatch(autoUpdateTimezone(timezone));
|
||||
dispatch(autoUpdateTimezone(getDeviceTimezone()));
|
||||
}
|
||||
|
||||
dispatch({
|
||||
@@ -62,7 +75,10 @@ export function scheduleExpiredNotification(intl) {
|
||||
const state = getState();
|
||||
const {currentUserId} = state.entities.users;
|
||||
const {deviceToken} = state.entities.general;
|
||||
const config = getConfig(state);
|
||||
const message = intl.formatMessage({
|
||||
id: 'mobile.session_expired',
|
||||
defaultMessage: 'Session Expired: Please log in to continue receiving notifications.',
|
||||
});
|
||||
|
||||
// Once the user logs in we are going to wait for 10 seconds
|
||||
// before retrieving the session that belongs to this device
|
||||
@@ -83,15 +99,6 @@ export function scheduleExpiredNotification(intl) {
|
||||
|
||||
const session = sessions.data.find((s) => s.device_id === deviceToken);
|
||||
const expiresAt = session?.expires_at || 0; //eslint-disable-line camelcase
|
||||
const expiresInDays = parseInt(Math.ceil(Math.abs(moment.duration(moment().diff(expiresAt)).asDays())), 10);
|
||||
|
||||
const message = intl.formatMessage({
|
||||
id: 'mobile.session_expired',
|
||||
defaultMessage: 'Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.',
|
||||
}, {
|
||||
siteName: config.SiteName,
|
||||
daysCount: expiresInDays,
|
||||
});
|
||||
|
||||
if (expiresAt) {
|
||||
PushNotifications.localNotificationSchedule({
|
||||
@@ -107,6 +114,8 @@ export function scheduleExpiredNotification(intl) {
|
||||
}
|
||||
|
||||
export default {
|
||||
handleLoginIdChanged,
|
||||
handlePasswordChanged,
|
||||
handleSuccessfulLogin,
|
||||
scheduleExpiredNotification,
|
||||
};
|
||||
|
||||
@@ -4,38 +4,60 @@
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
import {handleSuccessfulLogin} from 'app/actions/views/login';
|
||||
import {
|
||||
handleLoginIdChanged,
|
||||
handlePasswordChanged,
|
||||
} from 'app/actions/views/login';
|
||||
|
||||
jest.mock('app/init/credentials', () => ({
|
||||
setAppCredentials: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-native-cookies', () => ({
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
openURL: jest.fn(),
|
||||
canOpenURL: jest.fn(),
|
||||
getInitialURL: jest.fn(),
|
||||
get: () => Promise.resolve(({
|
||||
res: {
|
||||
MMCSRF: {
|
||||
value: 'the cookie',
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
describe('Actions.Views.Login', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'current-user-id',
|
||||
},
|
||||
general: {
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
store = mockStore({});
|
||||
});
|
||||
|
||||
test('handleSuccessfulLogin gets config and license ', async () => {
|
||||
const getClientConfig = jest.spyOn(Client4, 'getClientConfigOld');
|
||||
const getLicenseConfig = jest.spyOn(Client4, 'getClientLicenseOld');
|
||||
test('handleLoginIdChanged', () => {
|
||||
const loginId = 'email@example.com';
|
||||
|
||||
await store.dispatch(handleSuccessfulLogin());
|
||||
expect(getClientConfig).toHaveBeenCalled();
|
||||
expect(getLicenseConfig).toHaveBeenCalled();
|
||||
const action = {
|
||||
type: ViewTypes.LOGIN_ID_CHANGED,
|
||||
loginId,
|
||||
};
|
||||
store.dispatch(handleLoginIdChanged(loginId));
|
||||
expect(store.getActions()).toEqual([action]);
|
||||
});
|
||||
|
||||
test('handlePasswordChanged', () => {
|
||||
const password = 'password';
|
||||
const action = {
|
||||
type: ViewTypes.PASSWORD_CHANGED,
|
||||
password,
|
||||
};
|
||||
|
||||
store.dispatch(handlePasswordChanged(password));
|
||||
expect(store.getActions()).toEqual([action]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {getDirectChannelName} from '@mm-redux/utils/channel_utils';
|
||||
import {createDirectChannel, createGroupChannel} from '@mm-redux/actions/channels';
|
||||
import {getProfilesByIds, getStatusesByIds} from '@mm-redux/actions/users';
|
||||
import {getDirectChannelName} from 'mattermost-redux/utils/channel_utils';
|
||||
import {createDirectChannel, createGroupChannel} from 'mattermost-redux/actions/channels';
|
||||
import {getProfilesByIds, getStatusesByIds} from 'mattermost-redux/actions/users';
|
||||
import {handleSelectChannel, toggleDMChannel, toggleGMChannel} from 'app/actions/views/channel';
|
||||
|
||||
export function makeDirectChannel(otherUserId, switchToChannel = true) {
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
import {Posts} from 'mattermost-redux/constants';
|
||||
import {doPostAction, receivedNewPost} from 'mattermost-redux/actions/posts';
|
||||
|
||||
import {UserTypes} from '@mm-redux/action_types';
|
||||
import {
|
||||
doPostAction,
|
||||
getNeededAtMentionedUsernames,
|
||||
receivedNewPost,
|
||||
receivedPost,
|
||||
receivedPosts,
|
||||
receivedPostsBefore,
|
||||
receivedPostsInChannel,
|
||||
receivedPostsSince,
|
||||
receivedPostsInThread,
|
||||
} from '@mm-redux/actions/posts';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {Posts} from '@mm-redux/constants';
|
||||
import {getPost as selectPost, getPostIdsInChannel} from '@mm-redux/selectors/entities/posts';
|
||||
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
|
||||
import {removeUserFromList} from '@mm-redux/utils/user_utils';
|
||||
import {isUnreadChannel, isArchivedChannel} from '@mm-redux/utils/channel_utils';
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
import {ViewTypes} from '@constants';
|
||||
import {generateId} from '@utils/file';
|
||||
import {getChannelSinceValue} from '@utils/channels';
|
||||
|
||||
import {getEmojisInPosts} from './emoji';
|
||||
import {generateId} from 'app/utils/file';
|
||||
|
||||
export function sendAddToChannelEphemeralPost(user, addedUsername, message, channelId, postRootId = '') {
|
||||
return async (dispatch) => {
|
||||
@@ -78,406 +58,3 @@ export function selectAttachmentMenuAction(postId, actionId, text, value) {
|
||||
dispatch(doPostAction(postId, actionId, value));
|
||||
};
|
||||
}
|
||||
|
||||
export function getPosts(channelId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const state = getState();
|
||||
const {postsInChannel} = state.entities.posts;
|
||||
const postForChannel = postsInChannel[channelId];
|
||||
const data = await Client4.getPosts(channelId, page, perPage);
|
||||
const posts = Object.values(data.posts);
|
||||
const actions = [{
|
||||
type: ViewTypes.SET_CHANNEL_RETRY_FAILED,
|
||||
failed: false,
|
||||
}];
|
||||
|
||||
if (posts?.length) {
|
||||
actions.push(receivedPosts(data));
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
}
|
||||
|
||||
if (posts?.length || !postForChannel) {
|
||||
actions.push(receivedPostsInChannel(data, channelId, page === 0, data.prev_post_id === ''));
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POSTS'));
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getPost(postId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await Client4.getPost(postId);
|
||||
|
||||
if (data) {
|
||||
const actions = [
|
||||
receivedPost(data),
|
||||
];
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch([data]));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POST'));
|
||||
}
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostsSince(channelId, since) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await Client4.getPostsSince(channelId, since);
|
||||
const posts = Object.values(data.posts);
|
||||
|
||||
if (posts?.length) {
|
||||
const actions = [
|
||||
receivedPosts(data),
|
||||
receivedPostsSince(data, channelId),
|
||||
];
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POSTS_SINCE'));
|
||||
}
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostsBefore(channelId, postId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await Client4.getPostsBefore(channelId, postId, page, perPage);
|
||||
const posts = Object.values(data.posts);
|
||||
|
||||
if (posts?.length) {
|
||||
const actions = [
|
||||
receivedPosts(data),
|
||||
receivedPostsBefore(data, channelId, postId, data.prev_post_id === ''),
|
||||
];
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POSTS_BEFORE'));
|
||||
}
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostThread(rootId, skipDispatch = false) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = await Client4.getPostThread(rootId);
|
||||
const posts = Object.values(data.posts);
|
||||
|
||||
if (posts.length) {
|
||||
const actions = [
|
||||
receivedPosts(data),
|
||||
receivedPostsInThread(data, rootId),
|
||||
];
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
if (skipDispatch) {
|
||||
return {data: actions};
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POSTS_THREAD'));
|
||||
}
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostsAround(channelId, postId, perPage = Posts.POST_CHUNK_SIZE / 2) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const [before, thread, after] = await Promise.all([
|
||||
Client4.getPostsBefore(channelId, postId, 0, perPage),
|
||||
Client4.getPostThread(postId),
|
||||
Client4.getPostsAfter(channelId, postId, 0, perPage),
|
||||
]);
|
||||
|
||||
const data = {
|
||||
posts: {
|
||||
...after.posts,
|
||||
...thread.posts,
|
||||
...before.posts,
|
||||
},
|
||||
order: [ // Remember that the order is newest posts first
|
||||
...after.order,
|
||||
postId,
|
||||
...before.order,
|
||||
],
|
||||
next_post_id: after.next_post_id,
|
||||
prev_post_id: before.prev_post_id,
|
||||
};
|
||||
|
||||
const posts = Object.values(data.posts);
|
||||
|
||||
if (posts?.length) {
|
||||
const actions = [
|
||||
receivedPosts(data),
|
||||
receivedPostsInChannel(data, channelId, after.next_post_id === '', before.prev_post_id === ''),
|
||||
];
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_POSTS_AROUND'));
|
||||
}
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function handleNewPostBatch(WebSocketMessage) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const post = JSON.parse(WebSocketMessage.data.post);
|
||||
const actions = [receivedNewPost(post)];
|
||||
|
||||
// If we don't have the thread for this post, fetch it from the server
|
||||
// and include the actions in the batch
|
||||
if (post.root_id) {
|
||||
const rootPost = selectPost(state, post.root_id);
|
||||
|
||||
if (!rootPost) {
|
||||
const thread = await dispatch(getPostThread(post.root_id, true));
|
||||
if (thread.actions?.length) {
|
||||
actions.push(...thread.actions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch([post]));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostsAdditionalDataBatch(posts = []) {
|
||||
return async (dispatch, getState) => {
|
||||
const data = [];
|
||||
|
||||
if (!posts.length) {
|
||||
return {data};
|
||||
}
|
||||
|
||||
// Custom Emojis used in the posts
|
||||
// Do not wait for this as they need to be loaded one by one
|
||||
dispatch(getEmojisInPosts(posts));
|
||||
|
||||
try {
|
||||
const state = getState();
|
||||
const promises = [];
|
||||
const promiseTrace = [];
|
||||
const extra = userMetadataToLoadFromPosts(state, posts);
|
||||
|
||||
if (extra?.userIds.length) {
|
||||
promises.push(Client4.getProfilesByIds(extra.userIds));
|
||||
promiseTrace.push('ids');
|
||||
}
|
||||
|
||||
if (extra?.usernames.length) {
|
||||
promises.push(Client4.getProfilesByUsernames(extra.usernames));
|
||||
promiseTrace.push('usernames');
|
||||
}
|
||||
|
||||
if (extra?.statuses.length) {
|
||||
promises.push(Client4.getStatusesByIds(extra.statuses));
|
||||
promiseTrace.push('statuses');
|
||||
}
|
||||
|
||||
if (promises.length) {
|
||||
const result = await Promise.all(promises);
|
||||
result.forEach((p, index) => {
|
||||
if (p.length) {
|
||||
const type = promiseTrace[index];
|
||||
switch (type) {
|
||||
case 'statuses':
|
||||
data.push({
|
||||
type: UserTypes.RECEIVED_STATUSES,
|
||||
data: p,
|
||||
});
|
||||
break;
|
||||
default: {
|
||||
const {currentUserId} = state.entities.users;
|
||||
|
||||
removeUserFromList(currentUserId, p);
|
||||
data.push({
|
||||
type: UserTypes.RECEIVED_PROFILES_LIST,
|
||||
data: p,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return {data};
|
||||
};
|
||||
}
|
||||
|
||||
function userMetadataToLoadFromPosts(state, posts = []) {
|
||||
const {currentUserId, profiles, statuses} = state.entities.users;
|
||||
|
||||
// Profiles of users mentioned in the posts
|
||||
const usernamesToLoad = getNeededAtMentionedUsernames(state, posts);
|
||||
|
||||
// Statuses and profiles of the users who made the posts
|
||||
const userIdsToLoad = new Set();
|
||||
const statusesToLoad = new Set();
|
||||
|
||||
posts.forEach((post) => {
|
||||
const userId = post.user_id;
|
||||
|
||||
if (!statuses[userId]) {
|
||||
statusesToLoad.add(userId);
|
||||
}
|
||||
|
||||
if (userId === currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!profiles[userId]) {
|
||||
userIdsToLoad.add(userId);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
usernames: Array.from(usernamesToLoad),
|
||||
userIds: Array.from(userIdsToLoad),
|
||||
statuses: Array.from(statusesToLoad),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUnreadChannelPosts(channels, channelMembers) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
|
||||
const promises = [];
|
||||
const promiseTrace = [];
|
||||
|
||||
const channelMembersByChannel = {};
|
||||
channelMembers.forEach((member) => {
|
||||
channelMembersByChannel[member.channel_id] = member;
|
||||
});
|
||||
|
||||
channels.forEach((channel) => {
|
||||
if (channel.id === currentChannelId || isArchivedChannel(channel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isUnread = isUnreadChannel(channelMembersByChannel, channel);
|
||||
if (!isUnread) {
|
||||
return;
|
||||
}
|
||||
|
||||
const postIds = getPostIdsInChannel(state, channel.id);
|
||||
|
||||
let promise;
|
||||
const trace = {
|
||||
channelId: channel.id,
|
||||
since: false,
|
||||
};
|
||||
if (!postIds || !postIds.length) {
|
||||
// Get the first page of posts if it appears we haven't gotten it yet, like the webapp
|
||||
promise = Client4.getPosts(channel.id);
|
||||
} else {
|
||||
const since = getChannelSinceValue(state, channel.id, postIds);
|
||||
promise = Client4.getPostsSince(channel.id, since);
|
||||
trace.since = since;
|
||||
}
|
||||
|
||||
promises.push(promise);
|
||||
promiseTrace.push(trace);
|
||||
});
|
||||
|
||||
let posts = [];
|
||||
const actions = [];
|
||||
if (promises.length) {
|
||||
const results = await Promise.all(promises);
|
||||
results.forEach((data, index) => {
|
||||
const channelPosts = Object.values(data.posts);
|
||||
if (channelPosts.length) {
|
||||
posts = posts.concat(channelPosts);
|
||||
|
||||
const trace = promiseTrace[index];
|
||||
if (trace.since) {
|
||||
actions.push(receivedPostsSince(data, trace.channelId));
|
||||
} else {
|
||||
actions.push(receivedPostsInChannel(data, trace.channelId, true, data.prev_post_id === ''));
|
||||
}
|
||||
|
||||
actions.push({
|
||||
type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
|
||||
channelId: trace.channelId,
|
||||
time: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Fetched ${posts.length} posts from ${promises.length} unread channels`); //eslint-disable-line no-console
|
||||
if (posts.length) {
|
||||
// receivedPosts should be the first action dispatched as
|
||||
// receivedPostsSince and receivedPostsInChannel reducers are
|
||||
// dependent on it.
|
||||
actions.unshift(receivedPosts({posts}));
|
||||
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
||||
if (additional.data.length) {
|
||||
actions.push(...additional.data);
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable no-import-assign */
|
||||
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {PostTypes, UserTypes} from '@mm-redux/action_types';
|
||||
|
||||
import * as PostSelectors from '@mm-redux/selectors/entities/posts';
|
||||
import * as ChannelUtils from '@mm-redux/utils/channel_utils';
|
||||
|
||||
import {ViewTypes} from '@constants';
|
||||
import initialState from '@store/initial_state';
|
||||
|
||||
import {loadUnreadChannelPosts} from '@actions/views/post';
|
||||
|
||||
describe('Actions.Views.Post', () => {
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
let store;
|
||||
const currentChannelId = 'current-channel-id';
|
||||
const storeObj = {
|
||||
...initialState,
|
||||
entities: {
|
||||
...initialState.entities,
|
||||
channels: {
|
||||
...initialState.entities.channels,
|
||||
currentChannelId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const channels = [
|
||||
{id: 'channel-1'},
|
||||
{id: 'channel-2'},
|
||||
{id: 'channel-3'},
|
||||
];
|
||||
const channelMembers = [];
|
||||
|
||||
beforeEach(() => {
|
||||
ChannelUtils.isUnreadChannel = jest.fn().mockReturnValue(true);
|
||||
ChannelUtils.isArchivedChannel = jest.fn().mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts does not dispatch actions if no unread channels', async () => {
|
||||
ChannelUtils.isUnreadChannel = jest.fn().mockReturnValue(false);
|
||||
|
||||
store = mockStore(storeObj);
|
||||
await store.dispatch(loadUnreadChannelPosts(channels, channelMembers));
|
||||
|
||||
const storeActions = store.getActions();
|
||||
expect(storeActions).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts does not dispatch actions for archived channels', async () => {
|
||||
ChannelUtils.isArchivedChannel = jest.fn().mockReturnValue(true);
|
||||
Client4.getPosts = jest.fn().mockResolvedValue({posts: ['post-1', 'post-2']});
|
||||
|
||||
store = mockStore(storeObj);
|
||||
await store.dispatch(loadUnreadChannelPosts(channels, channelMembers));
|
||||
|
||||
const storeActions = store.getActions();
|
||||
expect(storeActions).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts does not dispatch actions for current channel', async () => {
|
||||
Client4.getPosts = jest.fn().mockResolvedValue({posts: ['post-1', 'post-2']});
|
||||
|
||||
store = mockStore(storeObj);
|
||||
await store.dispatch(loadUnreadChannelPosts([{id: currentChannelId}], channelMembers));
|
||||
|
||||
const storeActions = store.getActions();
|
||||
expect(storeActions).toStrictEqual([]);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts dispatches actions for unread channels with no postIds in channel', async () => {
|
||||
Client4.getPosts = jest.fn().mockResolvedValue({posts: ['post-1', 'post-2']});
|
||||
|
||||
store = mockStore(storeObj);
|
||||
await store.dispatch(loadUnreadChannelPosts(channels, channelMembers));
|
||||
|
||||
const actionTypes = store.getActions()[0].payload.map((action) => action.type);
|
||||
|
||||
// Actions dispatched:
|
||||
// RECEIVED_POSTS once and first, with all channel posts combined.
|
||||
// RECEIVED_POSTS_IN_CHANNEL and RECEIVED_POSTS_FOR_CHANNEL_AT_TIME for each channel.
|
||||
expect(actionTypes.length).toBe((2 * channels.length) + 1);
|
||||
expect(actionTypes[0]).toEqual(PostTypes.RECEIVED_POSTS);
|
||||
|
||||
const receivedPostsInChannelActions = actionTypes.filter((type) => type === PostTypes.RECEIVED_POSTS_IN_CHANNEL);
|
||||
expect(receivedPostsInChannelActions.length).toBe(channels.length);
|
||||
|
||||
const receivedPostsForChannelAtTimeActions = actionTypes.filter((type) => type === ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME);
|
||||
expect(receivedPostsForChannelAtTimeActions.length).toBe(channels.length);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts dispatches actions for unread channels with postIds in channel', async () => {
|
||||
PostSelectors.getPostIdsInChannel = jest.fn().mockReturnValue(['post-id-in-channel']);
|
||||
Client4.getPostsSince = jest.fn().mockResolvedValue({posts: ['post-1', 'post-2']});
|
||||
|
||||
const lastGetPosts = {};
|
||||
channels.forEach((channel) => {
|
||||
lastGetPosts[channel.id] = Date.now();
|
||||
});
|
||||
const lastConnectAt = Date.now() + 1000;
|
||||
store = mockStore({
|
||||
...storeObj,
|
||||
views: {
|
||||
channel: {
|
||||
lastGetPosts,
|
||||
},
|
||||
},
|
||||
websocket: {
|
||||
lastConnectAt,
|
||||
},
|
||||
});
|
||||
await store.dispatch(loadUnreadChannelPosts(channels, channelMembers));
|
||||
|
||||
const actionTypes = store.getActions()[0].payload.map((action) => action.type);
|
||||
|
||||
// Actions dispatched:
|
||||
// RECEIVED_POSTS once and first, with all channel posts combined.
|
||||
// RECEIVED_POSTS_SINCE and RECEIVED_POSTS_FOR_CHANNEL_AT_TIME for each channel.
|
||||
expect(actionTypes.length).toBe((2 * channels.length) + 1);
|
||||
expect(actionTypes[0]).toEqual(PostTypes.RECEIVED_POSTS);
|
||||
|
||||
const receivedPostsInChannelActions = actionTypes.filter((type) => type === PostTypes.RECEIVED_POSTS_SINCE);
|
||||
expect(receivedPostsInChannelActions.length).toBe(channels.length);
|
||||
|
||||
const receivedPostsForChannelAtTimeActions = actionTypes.filter((type) => type === ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME);
|
||||
expect(receivedPostsForChannelAtTimeActions.length).toBe(channels.length);
|
||||
});
|
||||
|
||||
test('loadUnreadChannelPosts dispatches additional actions for unread channels', async () => {
|
||||
const posts = [{
|
||||
user_id: 'user-id',
|
||||
message: '@user post-1',
|
||||
}];
|
||||
PostSelectors.getPostIdsInChannel = jest.fn().mockReturnValue(['post-id-in-channel']);
|
||||
Client4.getPostsSince = jest.fn().mockResolvedValue({posts});
|
||||
Client4.getProfilesByIds = jest.fn().mockResolvedValue(['data']);
|
||||
Client4.getProfilesByUsernames = jest.fn().mockResolvedValue(['data']);
|
||||
Client4.getStatusesByIds = jest.fn().mockResolvedValue(['data']);
|
||||
|
||||
const lastGetPosts = {};
|
||||
channels.forEach((channel) => {
|
||||
lastGetPosts[channel.id] = Date.now();
|
||||
});
|
||||
const lastConnectAt = Date.now() + 1000;
|
||||
store = mockStore({
|
||||
...storeObj,
|
||||
views: {
|
||||
channel: {
|
||||
lastGetPosts,
|
||||
},
|
||||
},
|
||||
websocket: {
|
||||
lastConnectAt,
|
||||
},
|
||||
});
|
||||
await store.dispatch(loadUnreadChannelPosts(channels, channelMembers));
|
||||
|
||||
const actionTypes = store.getActions()[0].payload.map((action) => action.type);
|
||||
|
||||
// Actions dispatched:
|
||||
// RECEIVED_POSTS once and first, with all channel posts combined.
|
||||
// RECEIVED_POSTS_SINCE and RECEIVED_POSTS_FOR_CHANNEL_AT_TIME for each channel.
|
||||
// RECEIVED_PROFILES_LIST twice, once for getProfilesByIds and once for getProfilesByUsernames
|
||||
// RECEIVED_STATUSES for getStatusesByIds
|
||||
expect(actionTypes.length).toBe((2 * channels.length) + 4);
|
||||
expect(actionTypes[0]).toEqual(PostTypes.RECEIVED_POSTS);
|
||||
|
||||
const receivedPostsInChannelActions = actionTypes.filter((type) => type === PostTypes.RECEIVED_POSTS_SINCE);
|
||||
expect(receivedPostsInChannelActions.length).toBe(channels.length);
|
||||
|
||||
const receivedPostsForChannelAtTimeActions = actionTypes.filter((type) => type === ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME);
|
||||
expect(receivedPostsForChannelAtTimeActions.length).toBe(channels.length);
|
||||
|
||||
const receivedProfiles = actionTypes.filter((type) => type === UserTypes.RECEIVED_PROFILES_LIST);
|
||||
expect(receivedProfiles.length).toBe(2);
|
||||
|
||||
const receivedStatuses = actionTypes.filter((type) => type === UserTypes.RECEIVED_STATUSES);
|
||||
expect(receivedStatuses.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,18 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
import {GeneralTypes} from 'mattermost-redux/action_types';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
import {fetchMyChannelsAndMembers} from 'mattermost-redux/actions/channels';
|
||||
import {getClientConfig, getDataRetentionPolicy, getLicenseConfig} from 'mattermost-redux/actions/general';
|
||||
import {receivedNewPost} from 'mattermost-redux/actions/posts';
|
||||
import {getMyTeams, getMyTeamMembers, selectTeam} from 'mattermost-redux/actions/teams';
|
||||
|
||||
import {NavigationTypes, ViewTypes} from '@constants';
|
||||
import {recordTime} from '@init/analytics.ts';
|
||||
import {ChannelTypes, GeneralTypes, TeamTypes} from '@mm-redux/action_types';
|
||||
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 {ViewTypes} from 'app/constants';
|
||||
import {recordTime} from 'app/utils/segment';
|
||||
|
||||
import {markChannelViewedAndRead} from './channel';
|
||||
import {handleSelectChannel} from 'app/actions/views/channel';
|
||||
|
||||
export function startDataCleanup() {
|
||||
return async (dispatch, getState) => {
|
||||
@@ -31,40 +26,28 @@ export function startDataCleanup() {
|
||||
export function loadConfigAndLicense() {
|
||||
return async (dispatch, getState) => {
|
||||
const {currentUserId} = getState().entities.users;
|
||||
const [configData, licenseData] = await Promise.all([
|
||||
getClientConfig()(dispatch, getState),
|
||||
getLicenseConfig()(dispatch, getState),
|
||||
]);
|
||||
|
||||
try {
|
||||
const [config, license] = await Promise.all([
|
||||
Client4.getClientConfigOld(),
|
||||
Client4.getClientLicenseOld(),
|
||||
]);
|
||||
const config = configData.data || {};
|
||||
const license = licenseData.data || {};
|
||||
|
||||
const actions = [{
|
||||
type: GeneralTypes.CLIENT_CONFIG_RECEIVED,
|
||||
data: config,
|
||||
}, {
|
||||
type: GeneralTypes.CLIENT_LICENSE_RECEIVED,
|
||||
data: license,
|
||||
}];
|
||||
|
||||
if (currentUserId) {
|
||||
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
|
||||
license.IsLicensed === 'true' && license.DataRetention === 'true') {
|
||||
dispatch(getDataRetentionPolicy());
|
||||
} else {
|
||||
actions.push({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
|
||||
}
|
||||
if (currentUserId) {
|
||||
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
|
||||
license.IsLicensed === 'true' && license.DataRetention === 'true') {
|
||||
getDataRetentionPolicy()(dispatch, getState);
|
||||
} else {
|
||||
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_LOAD_CONFIG_AND_LICENSE'));
|
||||
|
||||
return {config, license};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
return {config, license};
|
||||
};
|
||||
}
|
||||
|
||||
export function loadFromPushNotification(notification) {
|
||||
export function loadFromPushNotification(notification, startAppFromPushNotification) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {data} = notification;
|
||||
@@ -96,63 +79,20 @@ export function loadFromPushNotification(notification) {
|
||||
await Promise.all(loading);
|
||||
}
|
||||
|
||||
dispatch(handleSelectTeamAndChannel(teamId, channelId));
|
||||
};
|
||||
}
|
||||
|
||||
export function handleSelectTeamAndChannel(teamId, channelId) {
|
||||
return async (dispatch, getState) => {
|
||||
const dt = Date.now();
|
||||
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 = [];
|
||||
|
||||
// when the notification is from a team other than the current team
|
||||
if (teamId !== currentTeamId) {
|
||||
actions.push({type: TeamTypes.SELECT_TEAM, data: teamId});
|
||||
dispatch(selectTeam({id: teamId}));
|
||||
}
|
||||
|
||||
if (channel && currentChannelId !== channelId) {
|
||||
actions.push({
|
||||
type: ChannelTypes.SELECT_CHANNEL,
|
||||
data: channelId,
|
||||
extra: {
|
||||
channel,
|
||||
member,
|
||||
teamId: channel.team_id || currentTeamId,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch(markChannelViewedAndRead(channelId));
|
||||
}
|
||||
|
||||
if (actions.length) {
|
||||
dispatch(batchActions(actions, 'BATCH_SELECT_TEAM_AND_CHANNEL'));
|
||||
}
|
||||
|
||||
EphemeralStore.setStartFromNotification(false);
|
||||
|
||||
console.log('channel switch from push notification to', channel?.display_name, (Date.now() - dt), 'ms'); //eslint-disable-line
|
||||
dispatch(handleSelectChannel(channelId, startAppFromPushNotification));
|
||||
};
|
||||
}
|
||||
|
||||
export function purgeOfflineStore() {
|
||||
return (dispatch, getState) => {
|
||||
const currentState = getState();
|
||||
|
||||
dispatch({
|
||||
type: General.OFFLINE_STORE_PURGE,
|
||||
data: getStateForReset(initialState, currentState),
|
||||
});
|
||||
|
||||
EventEmitter.emit(NavigationTypes.RESTART_APP);
|
||||
};
|
||||
return {type: General.OFFLINE_STORE_PURGE};
|
||||
}
|
||||
|
||||
// A non-optimistic version of the createPost action in app/mm-redux with the file handling
|
||||
// A non-optimistic version of the createPost action in mattermost-redux with the file handling
|
||||
// removed since it's not needed.
|
||||
export function createPostForNotificationReply(post) {
|
||||
return async (dispatch, getState) => {
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
export function handleSearchDraftChanged(text) {
|
||||
return {
|
||||
type: ViewTypes.SEARCH_DRAFT_CHANGED,
|
||||
text,
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ViewTypes.SEARCH_DRAFT_CHANGED,
|
||||
text,
|
||||
}, getState);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
import {GeneralTypes} from '@mm-redux/action_types';
|
||||
import {GeneralTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
export function handleServerUrlChanged(serverUrl) {
|
||||
return batchActions([
|
||||
{type: GeneralTypes.CLIENT_CONFIG_RESET},
|
||||
{type: GeneralTypes.CLIENT_LICENSE_RESET},
|
||||
{type: ViewTypes.SERVER_URL_CHANGED, serverUrl},
|
||||
], 'BATCH_SERVER_URL_CHANGED');
|
||||
return async (dispatch, getState) => {
|
||||
dispatch(batchActions([
|
||||
{type: GeneralTypes.CLIENT_CONFIG_RESET},
|
||||
{type: GeneralTypes.CLIENT_LICENSE_RESET},
|
||||
{type: ViewTypes.SERVER_URL_CHANGED, serverUrl},
|
||||
]), getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function setServerUrl(serverUrl) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {batchActions} from 'redux-batched-actions';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import {GeneralTypes} from '@mm-redux/action_types';
|
||||
import {GeneralTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
@@ -20,13 +20,13 @@ describe('Actions.Views.SelectServer', () => {
|
||||
store = mockStore({});
|
||||
});
|
||||
|
||||
test('handleServerUrlChanged', () => {
|
||||
test('handleServerUrlChanged', async () => {
|
||||
const serverUrl = 'https://mattermost.example.com';
|
||||
const actions = batchActions([
|
||||
{type: GeneralTypes.CLIENT_CONFIG_RESET},
|
||||
{type: GeneralTypes.CLIENT_LICENSE_RESET},
|
||||
{type: ViewTypes.SERVER_URL_CHANGED, serverUrl},
|
||||
], 'BATCH_SERVER_URL_CHANGED');
|
||||
]);
|
||||
|
||||
store.dispatch(handleServerUrlChanged(serverUrl));
|
||||
expect(store.getActions()).toEqual([actions]);
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
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 {TeamTypes} from 'mattermost-redux/action_types';
|
||||
import {getMyTeams} from 'mattermost-redux/actions/teams';
|
||||
import {RequestStatus} from 'mattermost-redux/constants';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import EventEmitter from 'mattermost-redux/utils/event_emitter';
|
||||
|
||||
import {NavigationTypes} from 'app/constants';
|
||||
import {selectFirstAvailableTeam} from 'app/utils/teams';
|
||||
@@ -20,10 +18,7 @@ export function handleTeamChange(teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(batchActions([
|
||||
{type: TeamTypes.SELECT_TEAM, data: teamId},
|
||||
{type: ChannelTypes.SELECT_CHANNEL, data: '', extra: {}},
|
||||
], 'BATCH_SWITCH_TEAM'));
|
||||
dispatch({type: TeamTypes.SELECT_TEAM, data: teamId});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,16 +27,10 @@ export function selectDefaultTeam() {
|
||||
const state = getState();
|
||||
|
||||
const {ExperimentalPrimaryTeam} = getConfig(state);
|
||||
const {teams, myMembers} = state.entities.teams;
|
||||
const myTeams = Object.keys(teams).reduce((result, id) => {
|
||||
if (myMembers[id]) {
|
||||
result.push(teams[id]);
|
||||
}
|
||||
const {teams: allTeams, myMembers} = state.entities.teams;
|
||||
const teams = Object.keys(myMembers).map((key) => allTeams[key]);
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
let defaultTeam = selectFirstAvailableTeam(myTeams, ExperimentalPrimaryTeam);
|
||||
let defaultTeam = selectFirstAvailableTeam(teams, ExperimentalPrimaryTeam);
|
||||
|
||||
if (defaultTeam) {
|
||||
dispatch(handleTeamChange(defaultTeam.id));
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import {getSessions} from '@mm-redux/actions/users';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {getConfig} from '@mm-redux/selectors/entities/general';
|
||||
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
|
||||
|
||||
import PushNotifications from 'app/push_notifications';
|
||||
|
||||
const sortByNewest = (a, b) => {
|
||||
if (a.create_at > b.create_at) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
};
|
||||
|
||||
export function scheduleExpiredNotification(intl) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {currentUserId} = state.entities.users;
|
||||
const config = getConfig(state);
|
||||
|
||||
if (isMinimumServerVersion(Client4.serverVersion, 5, 24) && config.ExtendSessionLengthWithActivity === 'true') {
|
||||
PushNotifications.cancelAllLocalNotifications();
|
||||
return;
|
||||
}
|
||||
|
||||
let sessions;
|
||||
try {
|
||||
sessions = await dispatch(getSessions(currentUserId));
|
||||
} catch (e) {
|
||||
console.warn('Failed to get current session', e); // eslint-disable-line no-console
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(sessions.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.data.sort(sortByNewest)[0];
|
||||
const expiresAt = session?.expires_at || 0; //eslint-disable-line camelcase
|
||||
const expiresInDays = parseInt(Math.ceil(Math.abs(moment.duration(moment().diff(expiresAt)).asDays())), 10);
|
||||
|
||||
const message = intl.formatMessage({
|
||||
id: 'mobile.session_expired',
|
||||
defaultMessage: 'Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.',
|
||||
}, {
|
||||
siteName: config.SiteName,
|
||||
daysCount: expiresInDays,
|
||||
});
|
||||
|
||||
if (expiresAt) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Schedule Session Expiry Local Push Notification', expiresAt);
|
||||
PushNotifications.localNotificationSchedule({
|
||||
date: new Date(expiresAt),
|
||||
message,
|
||||
userInfo: {
|
||||
localNotification: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4,16 +4,12 @@
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
export function handleCommentDraftChanged(rootId, draft) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
if (state.views.thread.drafts[rootId]?.draft !== draft) {
|
||||
dispatch({
|
||||
type: ViewTypes.COMMENT_DRAFT_CHANGED,
|
||||
rootId,
|
||||
draft,
|
||||
});
|
||||
}
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ViewTypes.COMMENT_DRAFT_CHANGED,
|
||||
rootId,
|
||||
draft,
|
||||
}, getState);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,16 +17,10 @@ describe('Actions.Views.Thread', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({
|
||||
views: {
|
||||
thread: {
|
||||
drafts: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
store = mockStore({});
|
||||
});
|
||||
|
||||
test('handleCommentDraftChanged', () => {
|
||||
test('handleCommentDraftChanged', async () => {
|
||||
const rootId = '1234';
|
||||
const draft = 'draft1';
|
||||
const action = {
|
||||
@@ -34,11 +28,11 @@ describe('Actions.Views.Thread', () => {
|
||||
rootId,
|
||||
draft,
|
||||
};
|
||||
store.dispatch(handleCommentDraftChanged(rootId, draft));
|
||||
await store.dispatch(handleCommentDraftChanged(rootId, draft));
|
||||
expect(store.getActions()).toEqual([action]);
|
||||
});
|
||||
|
||||
test('handleCommentDraftSelectionChanged', () => {
|
||||
test('handleCommentDraftSelectionChanged', async () => {
|
||||
const rootId = '1234';
|
||||
const cursorPosition = 'position';
|
||||
const action = {
|
||||
@@ -46,7 +40,7 @@ describe('Actions.Views.Thread', () => {
|
||||
rootId,
|
||||
cursorPosition,
|
||||
};
|
||||
store.dispatch(handleCommentDraftSelectionChanged(rootId, cursorPosition));
|
||||
await store.dispatch(handleCommentDraftSelectionChanged(rootId, cursorPosition));
|
||||
expect(store.getActions()).toEqual([action]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {userTyping as wsUserTyping} from '@actions/websocket';
|
||||
import {userTyping as wsUserTyping} from 'mattermost-redux/actions/websocket';
|
||||
|
||||
export function userTyping(channelId, rootId) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {websocket} = state;
|
||||
const {websocket} = getState();
|
||||
if (websocket.connected) {
|
||||
wsUserTyping(state, channelId, rootId);
|
||||
wsUserTyping(channelId, rootId)(dispatch, getState);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,248 +1,20 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import {NavigationTypes} from 'app/constants';
|
||||
import {GeneralTypes, RoleTypes, UserTypes} from '@mm-redux/action_types';
|
||||
import {getDataRetentionPolicy} from '@mm-redux/actions/general';
|
||||
import * as HelperActions from '@mm-redux/actions/helpers';
|
||||
import {autoUpdateTimezone} from '@mm-redux/actions/timezone';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {General} from '@mm-redux/constants';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {getConfig, getLicense} from '@mm-redux/selectors/entities/general';
|
||||
import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone';
|
||||
import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities/users';
|
||||
|
||||
import {setAppCredentials} from 'app/init/credentials';
|
||||
import {setCSRFFromCookie} from '@utils/security';
|
||||
import {getDeviceTimezone} from '@utils/timezone';
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
|
||||
export function completeLogin(user, deviceToken) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const config = getConfig(state);
|
||||
const license = getLicense(state);
|
||||
const token = Client4.getToken();
|
||||
const url = Client4.getUrl();
|
||||
|
||||
setAppCredentials(deviceToken, user.id, token, url);
|
||||
|
||||
// Set timezone
|
||||
const enableTimezone = isTimezoneEnabled(state);
|
||||
if (enableTimezone) {
|
||||
const timezone = getDeviceTimezone();
|
||||
dispatch(autoUpdateTimezone(timezone));
|
||||
}
|
||||
|
||||
// Data retention
|
||||
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
|
||||
license.IsLicensed === 'true' && license.DataRetention === 'true') {
|
||||
dispatch(getDataRetentionPolicy());
|
||||
} else {
|
||||
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getMe() {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const data = {};
|
||||
data.me = await Client4.getMe();
|
||||
|
||||
const actions = [{
|
||||
type: UserTypes.RECEIVED_ME,
|
||||
data: data.me,
|
||||
}];
|
||||
|
||||
const roles = data.me.roles.split(' ');
|
||||
data.roles = await Client4.getRolesByNames(roles);
|
||||
if (data.roles.length) {
|
||||
actions.push({
|
||||
type: RoleTypes.RECEIVED_ROLES,
|
||||
data: data.roles,
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions, 'BATCH_GET_ME'));
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadMe(user, deviceToken, skipDispatch = false) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const data = {user};
|
||||
const deviceId = state.entities?.general?.deviceToken;
|
||||
|
||||
try {
|
||||
if (deviceId && !deviceToken && !skipDispatch) {
|
||||
await Client4.attachDevice(deviceId);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
data.user = await Client4.getMe();
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(forceLogoutIfNecessary(error));
|
||||
return {error};
|
||||
}
|
||||
|
||||
try {
|
||||
Client4.setUserId(data.user.id);
|
||||
Client4.setUserRoles(data.user.roles);
|
||||
|
||||
// Execute all other requests in parallel
|
||||
const teamsRequest = Client4.getMyTeams();
|
||||
const teamMembersRequest = Client4.getMyTeamMembers();
|
||||
const teamUnreadRequest = Client4.getMyTeamUnreads();
|
||||
const preferencesRequest = Client4.getMyPreferences();
|
||||
const configRequest = Client4.getClientConfigOld();
|
||||
const actions = [];
|
||||
|
||||
const [teams, teamMembers, teamUnreads, preferences, config] = await Promise.all([
|
||||
teamsRequest,
|
||||
teamMembersRequest,
|
||||
teamUnreadRequest,
|
||||
preferencesRequest,
|
||||
configRequest,
|
||||
]);
|
||||
|
||||
data.teams = teams;
|
||||
data.teamMembers = teamMembers;
|
||||
data.teamUnreads = teamUnreads;
|
||||
data.preferences = preferences;
|
||||
data.config = config;
|
||||
data.url = Client4.getUrl();
|
||||
|
||||
actions.push({
|
||||
type: UserTypes.LOGIN,
|
||||
data,
|
||||
});
|
||||
|
||||
const rolesToLoad = new Set();
|
||||
for (const role of data.user.roles.split(' ')) {
|
||||
rolesToLoad.add(role);
|
||||
}
|
||||
|
||||
for (const teamMember of teamMembers) {
|
||||
for (const role of teamMember.roles.split(' ')) {
|
||||
rolesToLoad.add(role);
|
||||
}
|
||||
}
|
||||
if (rolesToLoad.size > 0) {
|
||||
data.roles = await Client4.getRolesByNames(Array.from(rolesToLoad));
|
||||
if (data.roles.length) {
|
||||
actions.push({
|
||||
type: RoleTypes.RECEIVED_ROLES,
|
||||
data: data.roles,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipDispatch) {
|
||||
dispatch(batchActions(actions, 'BATCH_LOAD_ME'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('login error', error.stack); // eslint-disable-line no-console
|
||||
return {error};
|
||||
}
|
||||
|
||||
return {data};
|
||||
};
|
||||
}
|
||||
|
||||
export function login(loginId, password, mfaToken, ldapOnly = false) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const deviceToken = state.entities?.general?.deviceToken;
|
||||
let user;
|
||||
|
||||
try {
|
||||
user = await Client4.login(loginId, password, mfaToken, deviceToken, ldapOnly);
|
||||
await setCSRFFromCookie(Client4.getUrl());
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
const result = await dispatch(loadMe(user));
|
||||
|
||||
if (!result.error) {
|
||||
dispatch(completeLogin(user, deviceToken));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function ssoLogin(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) {
|
||||
dispatch(completeLogin(result.data.user, deviceToken));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function logout(skipServerLogout = false) {
|
||||
return async () => {
|
||||
if (!skipServerLogout) {
|
||||
try {
|
||||
Client4.logout();
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
EventEmitter.emit(NavigationTypes.NAVIGATION_RESET);
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function forceLogoutIfNecessary(error) {
|
||||
return async (dispatch) => {
|
||||
if (error.status_code === HTTP_UNAUTHORIZED && error.url && !error.url.includes('/login')) {
|
||||
dispatch(logout(true));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
import {UserTypes} from 'mattermost-redux/action_types';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
export function setCurrentUserStatusOffline() {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const status = getStatusForUserId(state, currentUserId);
|
||||
const currentUserId = getCurrentUserId(getState());
|
||||
|
||||
if (status !== General.OFFLINE) {
|
||||
dispatch({
|
||||
type: UserTypes.RECEIVED_STATUS,
|
||||
data: {
|
||||
user_id: currentUserId,
|
||||
status: General.OFFLINE,
|
||||
},
|
||||
});
|
||||
}
|
||||
return dispatch({
|
||||
type: UserTypes.RECEIVED_STATUS,
|
||||
data: {
|
||||
user_id: currentUserId,
|
||||
status: General.OFFLINE,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/* eslint-disable no-import-assign */
|
||||
HelperActions.forceLogoutIfNecessary = forceLogoutIfNecessary;
|
||||
@@ -4,14 +4,14 @@
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import {UserTypes} from '@mm-redux/action_types';
|
||||
import {General} from '@mm-redux/constants';
|
||||
import {UserTypes} from 'mattermost-redux/action_types';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
|
||||
import {setCurrentUserStatusOffline} from 'app/actions/views/user';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
jest.mock('@mm-redux/actions/users', () => ({
|
||||
jest.mock('mattermost-redux/actions/users', () => ({
|
||||
getStatus: (...args) => ({type: 'MOCK_GET_STATUS', args}),
|
||||
getStatusesByIds: (...args) => ({type: 'MOCK_GET_STATUS_BY_IDS', args}),
|
||||
startPeriodicStatusUpdates: () => ({type: 'MOCK_PERIODIC_STATUS_UPDATES'}),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,247 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Platform} from 'react-native';
|
||||
|
||||
const MAX_WEBSOCKET_FAILS = 7;
|
||||
const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec
|
||||
|
||||
const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins
|
||||
|
||||
class WebSocketClient {
|
||||
conn?: WebSocket;
|
||||
connectionUrl: string;
|
||||
token: string|null;
|
||||
sequence: number;
|
||||
connectFailCount: number;
|
||||
eventCallback?: Function;
|
||||
firstConnectCallback?: Function;
|
||||
reconnectCallback?: Function;
|
||||
errorCallback?: Function;
|
||||
closeCallback?: Function;
|
||||
connectingCallback?: Function;
|
||||
stop: boolean;
|
||||
connectionTimeout: any;
|
||||
|
||||
constructor() {
|
||||
this.connectionUrl = '';
|
||||
this.token = null;
|
||||
this.sequence = 1;
|
||||
this.connectFailCount = 0;
|
||||
this.stop = false;
|
||||
}
|
||||
|
||||
initialize(token: string|null, opts = {}) {
|
||||
const defaults = {
|
||||
forceConnection: true,
|
||||
connectionUrl: this.connectionUrl,
|
||||
};
|
||||
|
||||
const {connectionUrl, forceConnection, ...additionalOptions} = Object.assign({}, defaults, opts);
|
||||
|
||||
if (forceConnection) {
|
||||
this.stop = false;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.conn) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionUrl == null) {
|
||||
console.log('websocket must have connection url'); //eslint-disable-line no-console
|
||||
reject(new Error('websocket must have connection url'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connectFailCount === 0) {
|
||||
console.log('websocket connecting to ' + connectionUrl); //eslint-disable-line no-console
|
||||
}
|
||||
|
||||
if (this.connectingCallback) {
|
||||
this.connectingCallback();
|
||||
}
|
||||
|
||||
const regex = /^(?:https?|wss?):(?:\/\/)?[^/]*/;
|
||||
const captured = (regex).exec(connectionUrl);
|
||||
|
||||
let origin;
|
||||
if (captured) {
|
||||
origin = captured[0];
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
// this is done cause for android having the port 80 or 443 will fail the connection
|
||||
// the websocket will append them
|
||||
const split = origin.split(':');
|
||||
const port = split[2];
|
||||
if (port === '80' || port === '443') {
|
||||
origin = `${split[0]}:${split[1]}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If we're unable to set the origin header, the websocket won't connect, but the URL is likely malformed anyway
|
||||
const errorMessage = 'websocket failed to parse origin from ' + connectionUrl;
|
||||
console.warn(errorMessage); // eslint-disable-line no-console
|
||||
reject(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
this.conn = new WebSocket(connectionUrl, [], {headers: {origin}, ...(additionalOptions || {})});
|
||||
this.connectionUrl = connectionUrl;
|
||||
this.token = token;
|
||||
|
||||
this.conn!.onopen = () => {
|
||||
if (token) {
|
||||
// we check for the platform as a workaround until we fix on the server that further authentications
|
||||
// are ignored
|
||||
this.sendMessage('authentication_challenge', {token});
|
||||
}
|
||||
|
||||
if (this.connectFailCount > 0) {
|
||||
console.log('websocket re-established connection'); //eslint-disable-line no-console
|
||||
if (this.reconnectCallback) {
|
||||
this.reconnectCallback();
|
||||
}
|
||||
} else if (this.firstConnectCallback) {
|
||||
this.firstConnectCallback();
|
||||
}
|
||||
|
||||
this.connectFailCount = 0;
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.conn!.onclose = () => {
|
||||
this.conn = undefined;
|
||||
this.sequence = 1;
|
||||
|
||||
if (this.connectFailCount === 0) {
|
||||
console.log('websocket closed'); //eslint-disable-line no-console
|
||||
}
|
||||
|
||||
this.connectFailCount++;
|
||||
|
||||
if (this.closeCallback) {
|
||||
this.closeCallback(this.connectFailCount);
|
||||
}
|
||||
|
||||
let retryTime = MIN_WEBSOCKET_RETRY_TIME;
|
||||
|
||||
// If we've failed a bunch of connections then start backing off
|
||||
if (this.connectFailCount > MAX_WEBSOCKET_FAILS) {
|
||||
retryTime = MIN_WEBSOCKET_RETRY_TIME * this.connectFailCount;
|
||||
if (retryTime > MAX_WEBSOCKET_RETRY_TIME) {
|
||||
retryTime = MAX_WEBSOCKET_RETRY_TIME;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.connectionTimeout) {
|
||||
clearTimeout(this.connectionTimeout);
|
||||
}
|
||||
|
||||
this.connectionTimeout = setTimeout(
|
||||
() => {
|
||||
if (this.stop) {
|
||||
clearTimeout(this.connectionTimeout);
|
||||
return;
|
||||
}
|
||||
this.initialize(token, opts);
|
||||
},
|
||||
retryTime,
|
||||
);
|
||||
};
|
||||
|
||||
this.conn!.onerror = (evt: any) => {
|
||||
if (this.connectFailCount <= 1) {
|
||||
console.log('websocket error'); //eslint-disable-line no-console
|
||||
console.log(evt); //eslint-disable-line no-console
|
||||
}
|
||||
|
||||
if (this.errorCallback) {
|
||||
this.errorCallback(evt);
|
||||
}
|
||||
};
|
||||
|
||||
this.conn!.onmessage = (evt: any) => {
|
||||
const msg = JSON.parse(evt.data);
|
||||
if (msg.seq_reply) {
|
||||
if (msg.error) {
|
||||
console.warn(msg); //eslint-disable-line no-console
|
||||
}
|
||||
} else if (this.eventCallback) {
|
||||
this.eventCallback(msg);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setConnectingCallback(callback: Function) {
|
||||
this.connectingCallback = callback;
|
||||
}
|
||||
|
||||
setEventCallback(callback: Function) {
|
||||
this.eventCallback = callback;
|
||||
}
|
||||
|
||||
setFirstConnectCallback(callback: Function) {
|
||||
this.firstConnectCallback = callback;
|
||||
}
|
||||
|
||||
setReconnectCallback(callback: Function) {
|
||||
this.reconnectCallback = callback;
|
||||
}
|
||||
|
||||
setErrorCallback(callback: Function) {
|
||||
this.errorCallback = callback;
|
||||
}
|
||||
|
||||
setCloseCallback(callback: Function) {
|
||||
this.closeCallback = callback;
|
||||
}
|
||||
|
||||
close(stop = false) {
|
||||
this.stop = stop;
|
||||
this.connectFailCount = 0;
|
||||
this.sequence = 1;
|
||||
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
|
||||
this.conn.onclose = () => {}; //eslint-disable-line @typescript-eslint/no-empty-function
|
||||
this.conn.close();
|
||||
this.conn = undefined;
|
||||
console.log('websocket closed'); //eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(action: string, data: any) {
|
||||
const msg = {
|
||||
action,
|
||||
seq: this.sequence++,
|
||||
data,
|
||||
};
|
||||
|
||||
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
|
||||
this.conn.send(JSON.stringify(msg));
|
||||
} else if (!this.conn || this.conn.readyState === WebSocket.CLOSED) {
|
||||
this.conn = undefined;
|
||||
this.initialize(this.token);
|
||||
}
|
||||
}
|
||||
|
||||
userTyping(channelId: string, parentId: string) {
|
||||
this.sendMessage('user_typing', {
|
||||
channel_id: channelId,
|
||||
parent_id: parentId,
|
||||
});
|
||||
}
|
||||
|
||||
getStatuses() {
|
||||
this.sendMessage('get_statuses', null);
|
||||
}
|
||||
|
||||
getStatusesByIds(userIds: string[]) {
|
||||
this.sendMessage('get_statuses_by_ids', {
|
||||
user_ids: userIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new WebSocketClient();
|
||||
@@ -5,75 +5,63 @@ exports[`Badge should match snapshot 1`] = `
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"backgroundColor": "#444",
|
||||
"borderRadius": 20,
|
||||
"height": 20,
|
||||
"padding": 12,
|
||||
"paddingBottom": 3,
|
||||
"paddingTop": 3,
|
||||
"position": "absolute",
|
||||
"right": 30,
|
||||
"top": 2,
|
||||
},
|
||||
Object {
|
||||
"borderColor": "#000000",
|
||||
"backgroundColor": "#ffffff",
|
||||
},
|
||||
Object {
|
||||
"opacity": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"backgroundColor": "#444",
|
||||
"height": 20,
|
||||
"padding": 12,
|
||||
"paddingBottom": 3,
|
||||
"paddingTop": 3,
|
||||
},
|
||||
Object {
|
||||
"backgroundColor": "#ffffff",
|
||||
},
|
||||
Object {
|
||||
"opacity": 0,
|
||||
},
|
||||
]
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"alignSelf": "center",
|
||||
"flex": 1,
|
||||
"justifyContent": "center",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"alignSelf": "center",
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"justifyContent": "center",
|
||||
"textAlignVertical": "center",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
<Text
|
||||
onLayout={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"justifyContent": "center",
|
||||
"textAlignVertical": "center",
|
||||
}
|
||||
Array [
|
||||
Object {
|
||||
"color": "white",
|
||||
"fontSize": 14,
|
||||
},
|
||||
Object {
|
||||
"color": "#145dbf",
|
||||
"fontSize": 10,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
onLayout={[Function]}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"color": "white",
|
||||
"fontSize": 14,
|
||||
},
|
||||
Object {
|
||||
"color": "#145dbf",
|
||||
"fontSize": 10,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
99+
|
||||
</Text>
|
||||
</View>
|
||||
1
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Fade should render {opacity: 0} 1`] = `
|
||||
<ForwardRef(AnimatedComponentWrapper)
|
||||
pointerEvents="box-none"
|
||||
<AnimatedComponent
|
||||
style={
|
||||
Object {
|
||||
"opacity": 0,
|
||||
@@ -17,12 +16,11 @@ exports[`Fade should render {opacity: 0} 1`] = `
|
||||
<Text>
|
||||
text
|
||||
</Text>
|
||||
</ForwardRef(AnimatedComponentWrapper)>
|
||||
</AnimatedComponent>
|
||||
`;
|
||||
|
||||
exports[`Fade should render {opacity: 1} 1`] = `
|
||||
<ForwardRef(AnimatedComponentWrapper)
|
||||
pointerEvents="box-none"
|
||||
<AnimatedComponent
|
||||
style={
|
||||
Object {
|
||||
"opacity": 1,
|
||||
@@ -37,5 +35,5 @@ exports[`Fade should render {opacity: 1} 1`] = `
|
||||
<Text>
|
||||
text
|
||||
</Text>
|
||||
</ForwardRef(AnimatedComponentWrapper)>
|
||||
</AnimatedComponent>
|
||||
`;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FormattedTime should render correctly 1`] = `
|
||||
<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>
|
||||
`;
|
||||
@@ -1,20 +1,13 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`profile_picture_button should match snapshot 1`] = `
|
||||
<AttachmentButton
|
||||
<Connect(AttachmentButton)
|
||||
blurTextBox={[MockFunction]}
|
||||
browseFileTypes="public.item"
|
||||
canBrowseFiles={true}
|
||||
canBrowsePhotoLibrary={true}
|
||||
canBrowseVideoLibrary={true}
|
||||
canTakePhoto={true}
|
||||
canTakeVideo={true}
|
||||
extraOptions={
|
||||
Array [
|
||||
null,
|
||||
]
|
||||
}
|
||||
maxFileCount={5}
|
||||
maxFileSize={20971520}
|
||||
theme={
|
||||
Object {
|
||||
@@ -27,7 +20,6 @@ exports[`profile_picture_button should match snapshot 1`] = `
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
@@ -46,6 +38,5 @@ exports[`profile_picture_button should match snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
uploadFiles={[MockFunction]}
|
||||
validMimeTypes={Array []}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SendAction should change theme backgroundColor to 0.3 opacity 1`] = `
|
||||
exports[`SendButton should change theme backgroundColor to 0.3 opacity 1`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"justifyContent": "flex-end",
|
||||
"paddingRight": 8,
|
||||
"paddingHorizontal": 5,
|
||||
"paddingVertical": 3,
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -15,10 +16,10 @@ exports[`SendAction should change theme backgroundColor to 0.3 opacity 1`] = `
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"backgroundColor": "#166de0",
|
||||
"borderRadius": 4,
|
||||
"height": 32,
|
||||
"borderRadius": 18,
|
||||
"height": 28,
|
||||
"justifyContent": "center",
|
||||
"width": 80,
|
||||
"width": 28,
|
||||
},
|
||||
Object {
|
||||
"backgroundColor": "rgba(22,109,224,0.3)",
|
||||
@@ -27,74 +28,76 @@ exports[`SendAction should change theme backgroundColor to 0.3 opacity 1`] = `
|
||||
}
|
||||
>
|
||||
<PaperPlane
|
||||
color="rgba(255,255,255,0.5)"
|
||||
height={16}
|
||||
width={19}
|
||||
color="#ffffff"
|
||||
height={13}
|
||||
width={15}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`SendAction should match snapshot 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
exports[`SendButton should match snapshot 1`] = `
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.2}
|
||||
onPress={[MockFunction]}
|
||||
style={
|
||||
Object {
|
||||
"justifyContent": "flex-end",
|
||||
"paddingRight": 8,
|
||||
"paddingHorizontal": 5,
|
||||
"paddingVertical": 3,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"backgroundColor": "#166de0",
|
||||
"borderRadius": 4,
|
||||
"height": 32,
|
||||
"borderRadius": 18,
|
||||
"height": 28,
|
||||
"justifyContent": "center",
|
||||
"width": 80,
|
||||
"width": 28,
|
||||
}
|
||||
}
|
||||
>
|
||||
<PaperPlane
|
||||
color="#ffffff"
|
||||
height={16}
|
||||
width={19}
|
||||
height={13}
|
||||
width={15}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</TouchableOpacity>
|
||||
`;
|
||||
|
||||
exports[`SendAction should render theme backgroundColor 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
exports[`SendButton should render theme backgroundColor 1`] = `
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.2}
|
||||
onPress={[MockFunction]}
|
||||
style={
|
||||
Object {
|
||||
"justifyContent": "flex-end",
|
||||
"paddingRight": 8,
|
||||
"paddingHorizontal": 5,
|
||||
"paddingVertical": 3,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"backgroundColor": "#166de0",
|
||||
"borderRadius": 4,
|
||||
"height": 32,
|
||||
"borderRadius": 18,
|
||||
"height": 28,
|
||||
"justifyContent": "center",
|
||||
"width": 80,
|
||||
"width": 28,
|
||||
}
|
||||
}
|
||||
>
|
||||
<PaperPlane
|
||||
color="#ffffff"
|
||||
height={16}
|
||||
width={19}
|
||||
height={13}
|
||||
width={15}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</TouchableOpacity>
|
||||
`;
|
||||
@@ -1,38 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Swiper should match snapshot 1`] = `
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"backgroundColor": "transparent",
|
||||
"flex": 1,
|
||||
"position": "relative",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<ScrollView
|
||||
automaticallyAdjustContentInsets={true}
|
||||
bounces={false}
|
||||
contentContainerStyle={
|
||||
Array [
|
||||
Object {
|
||||
"backgroundColor": "transparent",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
horizontal={true}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onMomentumScrollEnd={[Function]}
|
||||
onScrollBeginDrag={[Function]}
|
||||
pagingEnabled={true}
|
||||
removeClippedSubviews={true}
|
||||
scrollEnabled={true}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
@@ -1,7 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AnnouncementBanner should match snapshot 1`] = `
|
||||
<ForwardRef(AnimatedComponentWrapper)
|
||||
<AnimatedComponent
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
@@ -18,17 +18,15 @@ exports[`AnnouncementBanner should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
>
|
||||
<Component
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.2}
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
},
|
||||
null,
|
||||
]
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
@@ -57,8 +55,8 @@ exports[`AnnouncementBanner should match snapshot 1`] = `
|
||||
name="info"
|
||||
size={16}
|
||||
/>
|
||||
</Component>
|
||||
</ForwardRef(AnimatedComponentWrapper)>
|
||||
</TouchableOpacity>
|
||||
</AnimatedComponent>
|
||||
`;
|
||||
|
||||
exports[`AnnouncementBanner should match snapshot 2`] = `null`;
|
||||
|
||||
@@ -13,20 +13,20 @@ import {intlShape} from 'react-intl';
|
||||
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||
|
||||
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;
|
||||
|
||||
export default class AnnouncementBanner extends PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
goToScreen: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
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 = {
|
||||
@@ -37,23 +37,24 @@ export default class AnnouncementBanner extends PureComponent {
|
||||
bannerHeight: new Animated.Value(0),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
componentWillMount() {
|
||||
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
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.props.bannerText !== nextProps.bannerText ||
|
||||
this.props.bannerEnabled !== nextProps.bannerEnabled ||
|
||||
this.props.bannerDismissed !== nextProps.bannerDismissed
|
||||
) {
|
||||
const showBanner = this.props.bannerEnabled && !this.props.bannerDismissed && Boolean(this.props.bannerText);
|
||||
const showBanner = nextProps.bannerEnabled && !nextProps.bannerDismissed && Boolean(nextProps.bannerText);
|
||||
this.toggleBanner(showBanner);
|
||||
}
|
||||
}
|
||||
|
||||
handlePress = () => {
|
||||
const {actions} = this.props;
|
||||
const {intl} = this.context;
|
||||
|
||||
const screen = 'ExpandedAnnouncementBanner';
|
||||
@@ -62,7 +63,7 @@ export default class AnnouncementBanner extends PureComponent {
|
||||
defaultMessage: 'Announcement',
|
||||
});
|
||||
|
||||
goToScreen(screen, title);
|
||||
actions.goToScreen(screen, title);
|
||||
};
|
||||
|
||||
toggleBanner = (show = true) => {
|
||||
@@ -70,7 +71,6 @@ export default class AnnouncementBanner extends PureComponent {
|
||||
Animated.timing(this.state.bannerHeight, {
|
||||
toValue: value,
|
||||
duration: 350,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
};
|
||||
|
||||
@@ -84,7 +84,6 @@ export default class AnnouncementBanner extends PureComponent {
|
||||
bannerColor,
|
||||
bannerText,
|
||||
bannerTextColor,
|
||||
isLandscape,
|
||||
} = this.props;
|
||||
|
||||
const bannerStyle = {
|
||||
@@ -102,7 +101,7 @@ export default class AnnouncementBanner extends PureComponent {
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={this.handlePress}
|
||||
style={[style.wrapper, padding(isLandscape)]}
|
||||
style={style.wrapper}
|
||||
>
|
||||
<Text
|
||||
ellipsizeMode='tail'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import Preferences from 'mattermost-redux/constants/preferences';
|
||||
|
||||
import AnnouncementBanner from './announcement_banner.js';
|
||||
|
||||
@@ -12,18 +12,20 @@ jest.useFakeTimers();
|
||||
|
||||
describe('AnnouncementBanner', () => {
|
||||
const baseProps = {
|
||||
actions: {
|
||||
goToScreen: jest.fn(),
|
||||
},
|
||||
bannerColor: '#ddd',
|
||||
bannerDismissed: false,
|
||||
bannerEnabled: true,
|
||||
bannerText: 'Banner Text',
|
||||
bannerTextColor: '#fff',
|
||||
theme: Preferences.THEMES.default,
|
||||
isLandscape: false,
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<AnnouncementBanner {...baseProps}/>,
|
||||
<AnnouncementBanner {...baseProps}/>
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {getConfig, getLicense} from '@mm-redux/selectors/entities/general';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {isLandscape} from 'app/selectors/device';
|
||||
import {goToScreen} from 'app/actions/navigation';
|
||||
|
||||
import AnnouncementBanner from './announcement_banner';
|
||||
|
||||
@@ -22,8 +23,15 @@ function mapStateToProps(state) {
|
||||
bannerText: config.BannerText,
|
||||
bannerTextColor: config.BannerTextColor || '#000',
|
||||
theme: getTheme(state),
|
||||
isLandscape: isLandscape(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(AnnouncementBanner);
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
goToScreen,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AnnouncementBanner);
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AtMention should match snapshot, no highlight 1`] = `
|
||||
<Text>
|
||||
@John.Smith
|
||||
</Text>
|
||||
`;
|
||||
|
||||
exports[`AtMention should match snapshot, with highlight 1`] = `
|
||||
<Text
|
||||
onLongPress={[Function]}
|
||||
onPress={[Function]}
|
||||
>
|
||||
<Text
|
||||
style={null}
|
||||
>
|
||||
@John.Smith
|
||||
</Text>
|
||||
|
||||
</Text>
|
||||
`;
|
||||
|
||||
exports[`AtMention should match snapshot, without highlight 1`] = `
|
||||
<Text
|
||||
onLongPress={[Function]}
|
||||
onPress={[Function]}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "#ff0000",
|
||||
}
|
||||
}
|
||||
>
|
||||
@Victor.Welch
|
||||
</Text>
|
||||
|
||||
</Text>
|
||||
`;
|
||||
@@ -6,17 +6,18 @@ import PropTypes from 'prop-types';
|
||||
import {Clipboard, Text} from 'react-native';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
import {displayUsername} from '@mm-redux/utils/user_utils';
|
||||
import {displayUsername} from 'mattermost-redux/utils/user_utils';
|
||||
|
||||
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 = {
|
||||
actions: PropTypes.shape({
|
||||
goToScreen: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
isSearchResult: PropTypes.bool,
|
||||
mentionKeys: PropTypes.array.isRequired,
|
||||
mentionName: PropTypes.string.isRequired,
|
||||
mentionStyle: CustomPropTypes.Style,
|
||||
onPostPress: PropTypes.func,
|
||||
@@ -33,19 +34,23 @@ export default class AtMention extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const user = this.getUserDetailsFromMentionName();
|
||||
const user = this.getUserDetailsFromMentionName(props);
|
||||
this.state = {
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.mentionName !== prevProps.mentionName || this.props.usersByUsername !== prevProps.usersByUsername) {
|
||||
this.updateUsername();
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.mentionName !== this.props.mentionName || nextProps.usersByUsername !== this.props.usersByUsername) {
|
||||
const user = this.getUserDetailsFromMentionName(nextProps);
|
||||
this.setState({
|
||||
user,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
goToUserProfile = () => {
|
||||
const {actions} = this.props;
|
||||
const {intl} = this.context;
|
||||
const screen = 'UserProfile';
|
||||
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
|
||||
@@ -53,16 +58,15 @@ export default class AtMention extends React.PureComponent {
|
||||
userId: this.state.user.id,
|
||||
};
|
||||
|
||||
goToScreen(screen, title, passProps);
|
||||
actions.goToScreen(screen, title, passProps);
|
||||
};
|
||||
|
||||
getUserDetailsFromMentionName() {
|
||||
const {usersByUsername} = this.props;
|
||||
let mentionName = this.props.mentionName.toLowerCase();
|
||||
getUserDetailsFromMentionName(props) {
|
||||
let mentionName = props.mentionName.toLowerCase();
|
||||
|
||||
while (mentionName.length > 0) {
|
||||
if (usersByUsername.hasOwnProperty(mentionName)) {
|
||||
return usersByUsername[mentionName];
|
||||
if (props.usersByUsername.hasOwnProperty(mentionName)) {
|
||||
return props.usersByUsername[mentionName];
|
||||
}
|
||||
|
||||
// Repeatedly trim off trailing punctuation in case this is at the end of a sentence
|
||||
@@ -109,15 +113,8 @@ export default class AtMention extends React.PureComponent {
|
||||
Clipboard.setString(`@${username}`);
|
||||
};
|
||||
|
||||
updateUsername = () => {
|
||||
const user = this.getUserDetailsFromMentionName();
|
||||
this.setState({
|
||||
user,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {isSearchResult, mentionName, mentionStyle, onPostPress, teammateNameDisplay, textStyle, mentionKeys} = this.props;
|
||||
const {isSearchResult, mentionName, mentionStyle, onPostPress, teammateNameDisplay, textStyle} = this.props;
|
||||
const {user} = this.state;
|
||||
|
||||
if (!user.username) {
|
||||
@@ -125,7 +122,6 @@ export default class AtMention extends React.PureComponent {
|
||||
}
|
||||
|
||||
const suffix = this.props.mentionName.substring(user.username.length);
|
||||
const highlighted = mentionKeys.some((item) => item.key === user.username);
|
||||
|
||||
return (
|
||||
<Text
|
||||
@@ -133,7 +129,7 @@ export default class AtMention extends React.PureComponent {
|
||||
onPress={isSearchResult ? onPostPress : this.goToUserProfile}
|
||||
onLongPress={this.handleLongPress}
|
||||
>
|
||||
<Text style={highlighted ? null : mentionStyle}>
|
||||
<Text style={mentionStyle}>
|
||||
{'@' + displayUsername(user, teammateNameDisplay)}
|
||||
</Text>
|
||||
{suffix}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
import AtMention from './at_mention.js';
|
||||
|
||||
describe('AtMention', () => {
|
||||
const baseProps = {
|
||||
usersByUsername: {},
|
||||
mentionKeys: [{key: 'John.Smith'}, {key: 'Jane.Doe'}],
|
||||
teammateNameDisplay: '',
|
||||
mentionName: 'John.Smith',
|
||||
mentionStyle: {color: '#ff0000'},
|
||||
theme: {},
|
||||
};
|
||||
|
||||
test('should match snapshot, no highlight', () => {
|
||||
const wrapper = shallow(
|
||||
<AtMention {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot, with highlight', () => {
|
||||
const wrapper = shallow(
|
||||
<AtMention {...baseProps}/>,
|
||||
);
|
||||
|
||||
wrapper.setState({user: {username: 'John.Smith'}});
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match snapshot, without highlight', () => {
|
||||
const wrapper = shallow(
|
||||
<AtMention {...baseProps}/>,
|
||||
);
|
||||
|
||||
wrapper.setState({user: {username: 'Victor.Welch'}});
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {getUsersByUsername, getCurrentUserMentionKeys} from '@mm-redux/selectors/entities/users';
|
||||
import {getUsersByUsername} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getTeammateNameDisplaySetting, getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {goToScreen} from 'app/actions/navigation';
|
||||
|
||||
import AtMention from './at_mention';
|
||||
|
||||
@@ -13,9 +16,16 @@ function mapStateToProps(state) {
|
||||
return {
|
||||
theme: getTheme(state),
|
||||
usersByUsername: getUsersByUsername(state),
|
||||
mentionKeys: getCurrentUserMentionKeys(state),
|
||||
teammateNameDisplay: getTeammateNameDisplaySetting(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(AtMention);
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
goToScreen,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AtMention);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AttachmentButton should match snapshot 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.2}
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -11,7 +12,6 @@ exports[`AttachmentButton should match snapshot 1`] = `
|
||||
"width": 45,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
>
|
||||
<Icon
|
||||
allowFontScaling={false}
|
||||
@@ -24,5 +24,5 @@ exports[`AttachmentButton should match snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
</TouchableWithFeedbackIOS>
|
||||
</TouchableOpacity>
|
||||
`;
|
||||
478
app/components/attachment_button/attachment_button.js
Normal file
478
app/components/attachment_button/attachment_button.js
Normal file
@@ -0,0 +1,478 @@
|
||||
// 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 {intlShape} from 'react-intl';
|
||||
import {
|
||||
Alert,
|
||||
NativeModules,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
|
||||
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 {lookupMimeType} from 'mattermost-redux/utils/file_utils';
|
||||
|
||||
import {PermissionTypes} from 'app/constants';
|
||||
import {changeOpacity} from 'app/utils/theme';
|
||||
import {t} from 'app/utils/i18n';
|
||||
|
||||
const ShareExtension = NativeModules.MattermostShare;
|
||||
|
||||
export default class AttachmentButton extends PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
showModalOverCurrentContext: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
blurTextBox: PropTypes.func.isRequired,
|
||||
browseFileTypes: PropTypes.string,
|
||||
validMimeTypes: PropTypes.array,
|
||||
canBrowseFiles: PropTypes.bool,
|
||||
canBrowsePhotoLibrary: PropTypes.bool,
|
||||
canBrowseVideoLibrary: PropTypes.bool,
|
||||
canTakePhoto: PropTypes.bool,
|
||||
canTakeVideo: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
fileCount: PropTypes.number,
|
||||
maxFileCount: PropTypes.number.isRequired,
|
||||
maxFileSize: PropTypes.number.isRequired,
|
||||
onShowFileMaxWarning: PropTypes.func,
|
||||
onShowFileSizeWarning: PropTypes.func,
|
||||
onShowUnsupportedMimeTypeWarning: PropTypes.func,
|
||||
theme: PropTypes.object.isRequired,
|
||||
uploadFiles: PropTypes.func.isRequired,
|
||||
wrapper: PropTypes.bool,
|
||||
extraOptions: PropTypes.arrayOf(PropTypes.object),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
browseFileTypes: Platform.OS === 'ios' ? 'public.item' : '*/*',
|
||||
validMimeTypes: [],
|
||||
canBrowseFiles: true,
|
||||
canBrowsePhotoLibrary: true,
|
||||
canBrowseVideoLibrary: true,
|
||||
canTakePhoto: true,
|
||||
canTakeVideo: true,
|
||||
maxFileCount: 5,
|
||||
extraOptions: null,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
attachPhotoFromCamera = () => {
|
||||
return this.attachFileFromCamera('photo', 'camera');
|
||||
};
|
||||
|
||||
attachFileFromCamera = async (mediaType, source) => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const options = {
|
||||
quality: 0.8,
|
||||
videoQuality: 'high',
|
||||
noData: true,
|
||||
mediaType,
|
||||
storageOptions: {
|
||||
cameraRoll: true,
|
||||
waitUntilSaved: true,
|
||||
},
|
||||
permissionDenied: {
|
||||
title: formatMessage({
|
||||
id: 'mobile.android.camera_permission_denied_title',
|
||||
defaultMessage: 'Camera access is required',
|
||||
}),
|
||||
text: formatMessage({
|
||||
id: 'mobile.android.camera_permission_denied_description',
|
||||
defaultMessage: 'To take photos and videos with your camera, please change your permission settings.',
|
||||
}),
|
||||
reTryTitle: formatMessage({
|
||||
id: 'mobile.android.permission_denied_retry',
|
||||
defaultMessage: 'Set Permission',
|
||||
}),
|
||||
okTitle: formatMessage({id: 'mobile.android.permission_denied_dismiss', defaultMessage: 'Dismiss'}),
|
||||
},
|
||||
};
|
||||
|
||||
const hasPhotoPermission = await this.hasPhotoPermission(source);
|
||||
|
||||
if (hasPhotoPermission) {
|
||||
ImagePicker.launchCamera(options, (response) => {
|
||||
if (response.error || response.didCancel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadFiles([response]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
attachFileFromLibrary = () => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const options = {
|
||||
quality: 0.8,
|
||||
noData: true,
|
||||
permissionDenied: {
|
||||
title: formatMessage({
|
||||
id: 'mobile.android.photos_permission_denied_title',
|
||||
defaultMessage: 'Photo library access is required',
|
||||
}),
|
||||
text: formatMessage({
|
||||
id: 'mobile.android.photos_permission_denied_description',
|
||||
defaultMessage: 'To upload images from your library, please change your permission settings.',
|
||||
}),
|
||||
reTryTitle: formatMessage({
|
||||
id: 'mobile.android.permission_denied_retry',
|
||||
defaultMessage: 'Set Permission',
|
||||
}),
|
||||
okTitle: formatMessage({id: 'mobile.android.permission_denied_dismiss', defaultMessage: 'Dismiss'}),
|
||||
},
|
||||
};
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
options.mediaType = 'mixed';
|
||||
}
|
||||
|
||||
ImagePicker.launchImageLibrary(options, (response) => {
|
||||
if (response.error || response.didCancel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadFiles([response]);
|
||||
});
|
||||
};
|
||||
|
||||
attachVideoFromCamera = () => {
|
||||
return this.attachFileFromCamera('video', 'camera');
|
||||
};
|
||||
|
||||
attachVideoFromLibraryAndroid = () => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const options = {
|
||||
videoQuality: 'high',
|
||||
mediaType: 'video',
|
||||
noData: true,
|
||||
permissionDenied: {
|
||||
title: formatMessage({
|
||||
id: 'mobile.android.videos_permission_denied_title',
|
||||
defaultMessage: 'Video library access is required',
|
||||
}),
|
||||
text: formatMessage({
|
||||
id: 'mobile.android.videos_permission_denied_description',
|
||||
defaultMessage: 'To upload videos from your library, please change your permission settings.',
|
||||
}),
|
||||
reTryTitle: formatMessage({
|
||||
id: 'mobile.android.permission_denied_retry',
|
||||
defaultMessage: 'Set Permission',
|
||||
}),
|
||||
okTitle: formatMessage({id: 'mobile.android.permission_denied_dismiss', defaultMessage: 'Dismiss'}),
|
||||
},
|
||||
};
|
||||
|
||||
ImagePicker.launchImageLibrary(options, (response) => {
|
||||
if (response.error || response.didCancel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadFiles([response]);
|
||||
});
|
||||
};
|
||||
|
||||
attachFileFromFiles = async () => {
|
||||
const {browseFileTypes} = this.props;
|
||||
const hasPermission = await this.hasStoragePermission();
|
||||
|
||||
if (hasPermission) {
|
||||
DocumentPicker.show({
|
||||
filetype: [browseFileTypes],
|
||||
}, async (error, res) => {
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
// For android we need to retrieve the realPath in case the file being imported is from the cloud
|
||||
const newUri = await ShareExtension.getFilePath(res.uri);
|
||||
if (newUri.filePath) {
|
||||
res.uri = newUri.filePath;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Decode file uri to get the actual path
|
||||
res.uri = decodeURIComponent(res.uri);
|
||||
|
||||
this.uploadFiles([res]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
hasPhotoPermission = async (source) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
const {formatMessage} = this.context.intl;
|
||||
let permissionRequest;
|
||||
const hasPermissionToStorage = await Permissions.check(source || 'photo');
|
||||
|
||||
switch (hasPermissionToStorage) {
|
||||
case PermissionTypes.UNDETERMINED:
|
||||
permissionRequest = await Permissions.request('photo');
|
||||
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case PermissionTypes.DENIED: {
|
||||
const canOpenSettings = await Permissions.canOpenSettings();
|
||||
let grantOption = null;
|
||||
if (canOpenSettings) {
|
||||
grantOption = {
|
||||
text: formatMessage({
|
||||
id: 'mobile.android.permission_denied_retry',
|
||||
defaultMessage: 'Set permission',
|
||||
}),
|
||||
onPress: () => Permissions.openSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
formatMessage({
|
||||
id: 'mobile.android.photos_permission_denied_title',
|
||||
defaultMessage: 'Photo library access is required',
|
||||
}),
|
||||
formatMessage({
|
||||
id: 'mobile.android.photos_permission_denied_description',
|
||||
defaultMessage: 'To upload images from your library, please change your permission settings.',
|
||||
}),
|
||||
[
|
||||
grantOption,
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'mobile.android.permission_denied_dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
}),
|
||||
},
|
||||
]
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
hasStoragePermission = async () => {
|
||||
if (Platform.OS === 'android') {
|
||||
const {formatMessage} = this.context.intl;
|
||||
let permissionRequest;
|
||||
const hasPermissionToStorage = await Permissions.check('storage');
|
||||
|
||||
switch (hasPermissionToStorage) {
|
||||
case PermissionTypes.UNDETERMINED:
|
||||
permissionRequest = await Permissions.request('storage');
|
||||
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case PermissionTypes.DENIED: {
|
||||
const canOpenSettings = await Permissions.canOpenSettings();
|
||||
let grantOption = null;
|
||||
if (canOpenSettings) {
|
||||
grantOption = {
|
||||
text: formatMessage({
|
||||
id: 'mobile.android.permission_denied_retry',
|
||||
defaultMessage: 'Set permission',
|
||||
}),
|
||||
onPress: () => Permissions.openSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
formatMessage({
|
||||
id: 'mobile.android.storage_permission_denied_title',
|
||||
defaultMessage: 'File Storage access is required',
|
||||
}),
|
||||
formatMessage({
|
||||
id: 'mobile.android.storage_permission_denied_description',
|
||||
defaultMessage: 'To upload images from your Android device, please change your permission settings.',
|
||||
}),
|
||||
[
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'mobile.android.permission_denied_dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
}),
|
||||
},
|
||||
grantOption,
|
||||
]
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
uploadFiles = async (files) => {
|
||||
const file = files[0];
|
||||
if (!file.fileSize | !file.fileName) {
|
||||
const path = (file.path || file.uri).replace('file://', '');
|
||||
const fileInfo = await RNFetchBlob.fs.stat(path);
|
||||
file.fileSize = fileInfo.size;
|
||||
file.fileName = fileInfo.filename;
|
||||
}
|
||||
|
||||
if (!file.type) {
|
||||
file.type = lookupMimeType(file.fileName);
|
||||
}
|
||||
|
||||
const {validMimeTypes} = this.props;
|
||||
if (validMimeTypes.length && !validMimeTypes.includes(file.type)) {
|
||||
this.props.onShowUnsupportedMimeTypeWarning();
|
||||
} else if (file.fileSize > this.props.maxFileSize) {
|
||||
this.props.onShowFileSizeWarning(file.fileName);
|
||||
} else {
|
||||
this.props.uploadFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
showFileAttachmentOptions = () => {
|
||||
const {
|
||||
canBrowseFiles,
|
||||
canBrowsePhotoLibrary,
|
||||
canBrowseVideoLibrary,
|
||||
canTakePhoto,
|
||||
canTakeVideo,
|
||||
fileCount,
|
||||
maxFileCount,
|
||||
onShowFileMaxWarning,
|
||||
extraOptions,
|
||||
actions,
|
||||
} = this.props;
|
||||
|
||||
if (fileCount === maxFileCount) {
|
||||
onShowFileMaxWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.blurTextBox();
|
||||
const items = [];
|
||||
|
||||
if (canTakePhoto) {
|
||||
items.push({
|
||||
action: this.attachPhotoFromCamera,
|
||||
text: {
|
||||
id: t('mobile.file_upload.camera_photo'),
|
||||
defaultMessage: 'Take Photo',
|
||||
},
|
||||
icon: 'camera',
|
||||
});
|
||||
}
|
||||
|
||||
if (canTakeVideo) {
|
||||
items.push({
|
||||
action: this.attachVideoFromCamera,
|
||||
text: {
|
||||
id: t('mobile.file_upload.camera_video'),
|
||||
defaultMessage: 'Take Video',
|
||||
},
|
||||
icon: 'video-camera',
|
||||
});
|
||||
}
|
||||
|
||||
if (canBrowsePhotoLibrary) {
|
||||
items.push({
|
||||
action: this.attachFileFromLibrary,
|
||||
text: {
|
||||
id: t('mobile.file_upload.library'),
|
||||
defaultMessage: 'Photo Library',
|
||||
},
|
||||
icon: 'photo',
|
||||
});
|
||||
}
|
||||
|
||||
if (canBrowseVideoLibrary && Platform.OS === 'android') {
|
||||
items.push({
|
||||
action: this.attachVideoFromLibraryAndroid,
|
||||
text: {
|
||||
id: t('mobile.file_upload.video'),
|
||||
defaultMessage: 'Video Library',
|
||||
},
|
||||
icon: 'file-video-o',
|
||||
});
|
||||
}
|
||||
|
||||
if (canBrowseFiles) {
|
||||
items.push({
|
||||
action: this.attachFileFromFiles,
|
||||
text: {
|
||||
id: t('mobile.file_upload.browse'),
|
||||
defaultMessage: 'Browse Files',
|
||||
},
|
||||
icon: 'file',
|
||||
});
|
||||
}
|
||||
|
||||
if (extraOptions) {
|
||||
extraOptions.forEach((option) => {
|
||||
if (option !== null) {
|
||||
items.push(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
actions.showModalOverCurrentContext('OptionsModal', {items});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {theme, wrapper, children} = this.props;
|
||||
|
||||
if (wrapper) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={this.showFileAttachmentOptions}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={this.showFileAttachmentOptions}
|
||||
style={style.buttonContainer}
|
||||
>
|
||||
<Icon
|
||||
size={30}
|
||||
style={style.attachIcon}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.9)}
|
||||
name='md-add'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
attachIcon: {
|
||||
marginTop: Platform.select({
|
||||
ios: 2,
|
||||
android: 0,
|
||||
}),
|
||||
},
|
||||
buttonContainer: {
|
||||
height: Platform.select({
|
||||
ios: 34,
|
||||
android: 36,
|
||||
}),
|
||||
width: 45,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
76
app/components/attachment_button/attachment_button.test.js
Normal file
76
app/components/attachment_button/attachment_button.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import Preferences from 'mattermost-redux/constants/preferences';
|
||||
|
||||
import {VALID_MIME_TYPES} from 'app/screens/edit_profile/edit_profile';
|
||||
import AttachmentButton from './attachment_button';
|
||||
|
||||
jest.mock('react-intl');
|
||||
|
||||
describe('AttachmentButton', () => {
|
||||
const baseProps = {
|
||||
actions: {
|
||||
showModalOverCurrentContext: jest.fn(),
|
||||
},
|
||||
theme: Preferences.THEMES.default,
|
||||
blurTextBox: jest.fn(),
|
||||
maxFileSize: 10,
|
||||
uploadFiles: jest.fn(),
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<AttachmentButton {...baseProps}/>
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should not upload file with invalid MIME type', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
validMimeTypes: VALID_MIME_TYPES,
|
||||
onShowUnsupportedMimeTypeWarning: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<AttachmentButton {...props}/>
|
||||
);
|
||||
|
||||
const file = {
|
||||
type: 'image/gif',
|
||||
fileSize: 10,
|
||||
fileName: 'test',
|
||||
};
|
||||
wrapper.instance().uploadFiles([file]);
|
||||
expect(props.onShowUnsupportedMimeTypeWarning).toHaveBeenCalled();
|
||||
expect(props.uploadFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should upload file with valid MIME type', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
validMimeTypes: VALID_MIME_TYPES,
|
||||
onShowUnsupportedMimeTypeWarning: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<AttachmentButton {...props}/>
|
||||
);
|
||||
|
||||
const file = {
|
||||
fileSize: 10,
|
||||
fileName: 'test',
|
||||
};
|
||||
VALID_MIME_TYPES.forEach((mimeType) => {
|
||||
file.type = mimeType;
|
||||
wrapper.instance().uploadFiles([file]);
|
||||
expect(props.onShowUnsupportedMimeTypeWarning).not.toHaveBeenCalled();
|
||||
expect(props.uploadFiles).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,533 +1,19 @@
|
||||
// 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 {intlShape} from 'react-intl';
|
||||
import {
|
||||
Alert,
|
||||
NativeModules,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
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 {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {lookupMimeType} from '@mm-redux/utils/file_utils';
|
||||
|
||||
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;
|
||||
import AttachmentButton from './attachment_button';
|
||||
|
||||
export default class AttachmentButton extends PureComponent {
|
||||
static propTypes = {
|
||||
blurTextBox: PropTypes.func.isRequired,
|
||||
browseFileTypes: PropTypes.string,
|
||||
validMimeTypes: PropTypes.array,
|
||||
canBrowseFiles: PropTypes.bool,
|
||||
canBrowsePhotoLibrary: PropTypes.bool,
|
||||
canBrowseVideoLibrary: PropTypes.bool,
|
||||
canTakePhoto: PropTypes.bool,
|
||||
canTakeVideo: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
fileCount: PropTypes.number,
|
||||
maxFileCount: PropTypes.number.isRequired,
|
||||
maxFileSize: PropTypes.number.isRequired,
|
||||
onShowFileMaxWarning: PropTypes.func,
|
||||
onShowFileSizeWarning: PropTypes.func,
|
||||
onShowUnsupportedMimeTypeWarning: PropTypes.func,
|
||||
theme: PropTypes.object.isRequired,
|
||||
uploadFiles: PropTypes.func.isRequired,
|
||||
wrapper: PropTypes.bool,
|
||||
extraOptions: PropTypes.arrayOf(PropTypes.object),
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
showModalOverCurrentContext,
|
||||
}, dispatch),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
browseFileTypes: Platform.OS === 'ios' ? 'public.item' : '*/*',
|
||||
validMimeTypes: [],
|
||||
canBrowseFiles: true,
|
||||
canBrowsePhotoLibrary: true,
|
||||
canBrowseVideoLibrary: true,
|
||||
canTakePhoto: true,
|
||||
canTakeVideo: true,
|
||||
maxFileCount: 5,
|
||||
extraOptions: null,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
getPermissionDeniedMessage = (source, mediaType = '') => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const applicationName = DeviceInfo.getApplicationName();
|
||||
switch (source) {
|
||||
case 'camera': {
|
||||
if (mediaType === 'video') {
|
||||
return {
|
||||
title: formatMessage({
|
||||
id: 'mobile.camera_video_permission_denied_title',
|
||||
defaultMessage: '{applicationName} would like to access your camera',
|
||||
}, {applicationName}),
|
||||
text: formatMessage({
|
||||
id: 'mobile.camera_video_permission_denied_description',
|
||||
defaultMessage: 'Take videos and upload them to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your camera.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: formatMessage({
|
||||
id: 'mobile.camera_photo_permission_denied_title',
|
||||
defaultMessage: '{applicationName} would like to access your camera',
|
||||
}, {applicationName}),
|
||||
text: formatMessage({
|
||||
id: 'mobile.camera_photo_permission_denied_description',
|
||||
defaultMessage: 'Take photos and upload them to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your camera.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
case 'storage':
|
||||
return {
|
||||
title: formatMessage({
|
||||
id: 'mobile.storage_permission_denied_title',
|
||||
defaultMessage: '{applicationName} would like to access your files',
|
||||
}, {applicationName}),
|
||||
text: formatMessage({
|
||||
id: 'mobile.storage_permission_denied_description',
|
||||
defaultMessage: 'Upload files to your Mattermost instance. Open Settings to grant Mattermost Read and Write access to files on this device.',
|
||||
}),
|
||||
};
|
||||
case 'video':
|
||||
return {
|
||||
title: formatMessage({
|
||||
id: 'mobile.android.videos_permission_denied_title',
|
||||
defaultMessage: '{applicationName} would like to access your videos',
|
||||
}, {applicationName}),
|
||||
text: formatMessage({
|
||||
id: 'mobile.android.videos_permission_denied_description',
|
||||
defaultMessage: 'Upload videos to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your video library.',
|
||||
}),
|
||||
};
|
||||
case 'photo':
|
||||
default: {
|
||||
if (Platform.OS === 'android') {
|
||||
return {
|
||||
title: formatMessage({
|
||||
id: 'mobile.android.photos_permission_denied_title',
|
||||
defaultMessage: '{applicationName} would like to access your photos',
|
||||
}, {applicationName}),
|
||||
text: formatMessage({
|
||||
id: 'mobile.android.photos_permission_denied_description',
|
||||
defaultMessage: 'Upload photos to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your photo library.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: formatMessage({
|
||||
id: 'mobile.ios.photos_permission_denied_title',
|
||||
defaultMessage: '{applicationName} would like to access your photos',
|
||||
}, {applicationName}),
|
||||
text: formatMessage({
|
||||
id: 'mobile.ios.photos_permission_denied_description',
|
||||
defaultMessage: 'Upload photos and videos to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your photo and video library.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attachPhotoFromCamera = () => {
|
||||
return this.attachFileFromCamera('camera', 'photo');
|
||||
};
|
||||
|
||||
attachFileFromCamera = async (source, mediaType) => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const {title, text} = this.getPermissionDeniedMessage('camera', mediaType);
|
||||
const options = {
|
||||
quality: 0.8,
|
||||
videoQuality: 'high',
|
||||
noData: true,
|
||||
mediaType,
|
||||
storageOptions: {
|
||||
cameraRoll: true,
|
||||
waitUntilSaved: true,
|
||||
},
|
||||
permissionDenied: {
|
||||
title,
|
||||
text,
|
||||
reTryTitle: formatMessage({
|
||||
id: 'mobile.permission_denied_retry',
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
okTitle: formatMessage({id: 'mobile.permission_denied_dismiss', defaultMessage: 'Don\'t Allow'}),
|
||||
},
|
||||
};
|
||||
|
||||
const hasCameraPermission = await this.hasPhotoPermission(source, mediaType);
|
||||
|
||||
if (hasCameraPermission) {
|
||||
ImagePicker.launchCamera(options, (response) => {
|
||||
StatusBar.setHidden(false);
|
||||
emmProvider.inBackgroundSince = null;
|
||||
if (response.error || response.didCancel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadFiles([response]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
attachFileFromLibrary = async () => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const {title, text} = this.getPermissionDeniedMessage('photo');
|
||||
const options = {
|
||||
quality: 0.8,
|
||||
noData: true,
|
||||
permissionDenied: {
|
||||
title,
|
||||
text,
|
||||
reTryTitle: formatMessage({
|
||||
id: 'mobile.permission_denied_retry',
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
okTitle: formatMessage({id: 'mobile.permission_denied_dismiss', defaultMessage: 'Don\'t Allow'}),
|
||||
},
|
||||
};
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
options.mediaType = 'mixed';
|
||||
}
|
||||
|
||||
const hasPhotoPermission = await this.hasPhotoPermission('photo', 'photo');
|
||||
|
||||
if (hasPhotoPermission) {
|
||||
ImagePicker.launchImageLibrary(options, (response) => {
|
||||
StatusBar.setHidden(false);
|
||||
emmProvider.inBackgroundSince = null;
|
||||
if (response.error || response.didCancel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadFiles([response]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
attachVideoFromCamera = () => {
|
||||
return this.attachFileFromCamera('camera', 'video');
|
||||
};
|
||||
|
||||
attachVideoFromLibraryAndroid = () => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const {title, text} = this.getPermissionDeniedMessage('video');
|
||||
const options = {
|
||||
videoQuality: 'high',
|
||||
mediaType: 'video',
|
||||
noData: true,
|
||||
permissionDenied: {
|
||||
title,
|
||||
text,
|
||||
reTryTitle: formatMessage({
|
||||
id: 'mobile.permission_denied_retry',
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
okTitle: formatMessage({id: 'mobile.permission_denied_dismiss', defaultMessage: 'Don\'t Allow'}),
|
||||
},
|
||||
};
|
||||
|
||||
ImagePicker.launchImageLibrary(options, (response) => {
|
||||
emmProvider.inBackgroundSince = null;
|
||||
if (response.error || response.didCancel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadFiles([response]);
|
||||
});
|
||||
};
|
||||
|
||||
attachFileFromFiles = async () => {
|
||||
const {browseFileTypes} = this.props;
|
||||
const hasPermission = await this.hasStoragePermission();
|
||||
|
||||
if (hasPermission) {
|
||||
try {
|
||||
const res = await DocumentPicker.pick({type: [browseFileTypes]});
|
||||
emmProvider.inBackgroundSince = null;
|
||||
if (Platform.OS === 'android') {
|
||||
// For android we need to retrieve the realPath in case the file being imported is from the cloud
|
||||
const newUri = await ShareExtension.getFilePath(res.uri);
|
||||
if (newUri.filePath) {
|
||||
res.uri = newUri.filePath;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Decode file uri to get the actual path
|
||||
res.uri = decodeURIComponent(res.uri);
|
||||
|
||||
this.uploadFiles([res]);
|
||||
} catch (error) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
hasPhotoPermission = async (source, mediaType = '') => {
|
||||
if (Platform.OS === 'ios') {
|
||||
const {formatMessage} = this.context.intl;
|
||||
let permissionRequest;
|
||||
const targetSource = source === 'camera' ? Permissions.PERMISSIONS.IOS.CAMERA : Permissions.PERMISSIONS.IOS.PHOTO_LIBRARY;
|
||||
const hasPermissionToStorage = await Permissions.check(targetSource);
|
||||
|
||||
switch (hasPermissionToStorage) {
|
||||
case Permissions.RESULTS.DENIED:
|
||||
permissionRequest = await Permissions.request(targetSource);
|
||||
if (permissionRequest !== Permissions.RESULTS.GRANTED) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case Permissions.RESULTS.BLOCKED: {
|
||||
const grantOption = {
|
||||
text: formatMessage({id: 'mobile.permission_denied_retry', defaultMessage: 'Settings'}),
|
||||
onPress: () => Permissions.openSettings(),
|
||||
};
|
||||
|
||||
const {title, text} = this.getPermissionDeniedMessage(source, mediaType);
|
||||
|
||||
Alert.alert(
|
||||
title,
|
||||
text,
|
||||
[
|
||||
grantOption,
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'mobile.permission_denied_dismiss',
|
||||
defaultMessage: 'Don\'t Allow',
|
||||
}),
|
||||
},
|
||||
],
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
hasStoragePermission = async () => {
|
||||
if (Platform.OS === 'android') {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const storagePermission = Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE;
|
||||
let permissionRequest;
|
||||
const hasPermissionToStorage = await Permissions.check(storagePermission);
|
||||
|
||||
switch (hasPermissionToStorage) {
|
||||
case Permissions.RESULTS.DENIED:
|
||||
permissionRequest = await Permissions.request(storagePermission);
|
||||
if (permissionRequest !== Permissions.RESULTS.GRANTED) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case Permissions.RESULTS.BLOCKED: {
|
||||
const {title, text} = this.getPermissionDeniedMessage(storagePermission);
|
||||
|
||||
Alert.alert(
|
||||
title,
|
||||
text,
|
||||
[
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'mobile.permission_denied_dismiss',
|
||||
defaultMessage: 'Don\'t Allow',
|
||||
}),
|
||||
},
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'mobile.permission_denied_retry',
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
onPress: () => AndroidOpenSettings.appDetailsSettings(),
|
||||
},
|
||||
],
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
uploadFiles = async (files) => {
|
||||
const file = files[0];
|
||||
if (!file.fileSize | !file.fileName) {
|
||||
const path = (file.path || file.uri).replace('file://', '');
|
||||
const fileInfo = await RNFetchBlob.fs.stat(path);
|
||||
file.fileSize = fileInfo.size;
|
||||
file.fileName = fileInfo.filename;
|
||||
}
|
||||
|
||||
if (!file.type) {
|
||||
file.type = lookupMimeType(file.fileName);
|
||||
}
|
||||
|
||||
const {validMimeTypes} = this.props;
|
||||
if (validMimeTypes.length && !validMimeTypes.includes(file.type)) {
|
||||
this.props.onShowUnsupportedMimeTypeWarning();
|
||||
} else if (file.fileSize > this.props.maxFileSize) {
|
||||
this.props.onShowFileSizeWarning(file.fileName);
|
||||
} else {
|
||||
this.props.uploadFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
showFileAttachmentOptions = () => {
|
||||
const {
|
||||
canBrowseFiles,
|
||||
canBrowsePhotoLibrary,
|
||||
canBrowseVideoLibrary,
|
||||
canTakePhoto,
|
||||
canTakeVideo,
|
||||
fileCount,
|
||||
maxFileCount,
|
||||
onShowFileMaxWarning,
|
||||
extraOptions,
|
||||
} = this.props;
|
||||
|
||||
if (fileCount === maxFileCount) {
|
||||
onShowFileMaxWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.blurTextBox();
|
||||
const items = [];
|
||||
|
||||
if (canTakePhoto) {
|
||||
items.push({
|
||||
action: this.attachPhotoFromCamera,
|
||||
text: {
|
||||
id: t('mobile.file_upload.camera_photo'),
|
||||
defaultMessage: 'Take Photo',
|
||||
},
|
||||
icon: 'camera',
|
||||
});
|
||||
}
|
||||
|
||||
if (canTakeVideo) {
|
||||
items.push({
|
||||
action: this.attachVideoFromCamera,
|
||||
text: {
|
||||
id: t('mobile.file_upload.camera_video'),
|
||||
defaultMessage: 'Take Video',
|
||||
},
|
||||
icon: 'video-camera',
|
||||
});
|
||||
}
|
||||
|
||||
if (canBrowsePhotoLibrary) {
|
||||
items.push({
|
||||
action: this.attachFileFromLibrary,
|
||||
text: {
|
||||
id: t('mobile.file_upload.library'),
|
||||
defaultMessage: 'Photo Library',
|
||||
},
|
||||
icon: 'photo',
|
||||
});
|
||||
}
|
||||
|
||||
if (canBrowseVideoLibrary && Platform.OS === 'android') {
|
||||
items.push({
|
||||
action: this.attachVideoFromLibraryAndroid,
|
||||
text: {
|
||||
id: t('mobile.file_upload.video'),
|
||||
defaultMessage: 'Video Library',
|
||||
},
|
||||
icon: 'file-video-o',
|
||||
});
|
||||
}
|
||||
|
||||
if (canBrowseFiles) {
|
||||
items.push({
|
||||
action: this.attachFileFromFiles,
|
||||
text: {
|
||||
id: t('mobile.file_upload.browse'),
|
||||
defaultMessage: 'Browse Files',
|
||||
},
|
||||
icon: 'file',
|
||||
});
|
||||
}
|
||||
|
||||
if (extraOptions) {
|
||||
extraOptions.forEach((option) => {
|
||||
if (option !== null) {
|
||||
items.push(option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showModalOverCurrentContext('OptionsModal', {items});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {theme, wrapper, children} = this.props;
|
||||
|
||||
if (wrapper) {
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.showFileAttachmentOptions}
|
||||
type={'opacity'}
|
||||
>
|
||||
{children}
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.showFileAttachmentOptions}
|
||||
style={style.buttonContainer}
|
||||
type={'opacity'}
|
||||
>
|
||||
<Icon
|
||||
size={30}
|
||||
style={style.attachIcon}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.9)}
|
||||
name='md-add'
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
attachIcon: {
|
||||
marginTop: Platform.select({
|
||||
ios: 2,
|
||||
android: -5,
|
||||
}),
|
||||
},
|
||||
buttonContainer: {
|
||||
height: Platform.select({
|
||||
ios: 34,
|
||||
android: 36,
|
||||
}),
|
||||
width: 45,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
export default connect(null, mapDispatchToProps)(AttachmentButton);
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import Permissions from 'react-native-permissions';
|
||||
import {Alert, StatusBar} from 'react-native';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
import {VALID_MIME_TYPES} from 'app/screens/edit_profile/edit_profile';
|
||||
|
||||
import AttachmentButton from './index';
|
||||
|
||||
jest.mock('react-intl');
|
||||
jest.mock('react-native-image-picker', () => ({
|
||||
launchCamera: jest.fn().mockImplementation((options, callback) => callback({didCancel: true})),
|
||||
launchImageLibrary: jest.fn().mockImplementation((options, callback) => callback({didCancel: true})),
|
||||
}));
|
||||
|
||||
describe('AttachmentButton', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
theme: Preferences.THEMES.default,
|
||||
blurTextBox: jest.fn(),
|
||||
maxFileSize: 10,
|
||||
uploadFiles: jest.fn(),
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(<AttachmentButton {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should not upload file with invalid MIME type', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
validMimeTypes: VALID_MIME_TYPES,
|
||||
onShowUnsupportedMimeTypeWarning: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(<AttachmentButton {...props}/>);
|
||||
|
||||
const file = {
|
||||
type: 'image/gif',
|
||||
fileSize: 10,
|
||||
fileName: 'test',
|
||||
};
|
||||
wrapper.instance().uploadFiles([file]);
|
||||
expect(props.onShowUnsupportedMimeTypeWarning).toHaveBeenCalled();
|
||||
expect(props.uploadFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should upload file with valid MIME type', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
validMimeTypes: VALID_MIME_TYPES,
|
||||
onShowUnsupportedMimeTypeWarning: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(<AttachmentButton {...props}/>);
|
||||
|
||||
const file = {
|
||||
fileSize: 10,
|
||||
fileName: 'test',
|
||||
};
|
||||
VALID_MIME_TYPES.forEach((mimeType) => {
|
||||
file.type = mimeType;
|
||||
wrapper.instance().uploadFiles([file]);
|
||||
expect(props.onShowUnsupportedMimeTypeWarning).not.toHaveBeenCalled();
|
||||
expect(props.uploadFiles).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should return permission false if permission is denied in iOS', async () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
|
||||
const wrapper = shallow(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
|
||||
expect(Permissions.check).toHaveBeenCalled();
|
||||
expect(Permissions.request).toHaveBeenCalled();
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
expect(hasPhotoPermission).toBe(false);
|
||||
});
|
||||
|
||||
test('should show permission denied alert and return permission false if permission is blocked in iOS', async () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
|
||||
jest.spyOn(Alert, 'alert').mockReturnValue(true);
|
||||
|
||||
const wrapper = shallow(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
|
||||
expect(Permissions.check).toHaveBeenCalled();
|
||||
expect(Permissions.request).not.toHaveBeenCalled();
|
||||
expect(Alert.alert).toHaveBeenCalled();
|
||||
expect(hasPhotoPermission).toBe(false);
|
||||
});
|
||||
|
||||
test('should re-enable StatusBar after ImagePicker launchCamera finishes', async () => {
|
||||
const wrapper = shallow(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
jest.spyOn(instance, 'hasPhotoPermission').mockReturnValue(true);
|
||||
jest.spyOn(StatusBar, 'setHidden');
|
||||
|
||||
await instance.attachFileFromCamera();
|
||||
expect(StatusBar.setHidden).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('should re-enable StatusBar after ImagePicker launchImageLibrary finishes', async () => {
|
||||
const wrapper = shallow(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
jest.spyOn(instance, 'hasPhotoPermission').mockReturnValue(true);
|
||||
jest.spyOn(StatusBar, 'setHidden');
|
||||
|
||||
await instance.attachFileFromLibrary();
|
||||
expect(StatusBar.setHidden).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {SectionList} from 'react-native';
|
||||
|
||||
import {RequestStatus} from '@mm-redux/constants';
|
||||
import {RequestStatus} from 'mattermost-redux/constants';
|
||||
|
||||
import {AT_MENTION_REGEX, AT_MENTION_SEARCH_REGEX} from 'app/constants/autocomplete';
|
||||
import AtMentionItem from 'app/components/autocomplete/at_mention_item';
|
||||
@@ -22,7 +22,7 @@ export default class AtMention extends PureComponent {
|
||||
}).isRequired,
|
||||
currentChannelId: PropTypes.string,
|
||||
currentTeamId: PropTypes.string.isRequired,
|
||||
cursorPosition: PropTypes.number,
|
||||
cursorPosition: PropTypes.number.isRequired,
|
||||
defaultChannel: PropTypes.object,
|
||||
inChannel: PropTypes.array,
|
||||
isSearch: PropTypes.bool,
|
||||
@@ -35,9 +35,6 @@ 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,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -101,7 +98,7 @@ export default class AtMention extends PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.useChannelMentions && this.checkSpecialMentions(matchTerm)) {
|
||||
if (this.checkSpecialMentions(matchTerm)) {
|
||||
sections.push({
|
||||
id: t('suggestion.mention.special'),
|
||||
defaultMessage: 'Special Mentions',
|
||||
@@ -181,7 +178,6 @@ export default class AtMention extends PureComponent {
|
||||
id={section.id}
|
||||
defaultMessage={section.defaultMessage}
|
||||
theme={this.props.theme}
|
||||
isLandscape={this.props.isLandscape}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -209,7 +205,7 @@ export default class AtMention extends PureComponent {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {maxListHeight, theme, nestedScrollEnabled} = this.props;
|
||||
const {maxListHeight, theme} = this.props;
|
||||
const {mentionComplete, sections} = this.state;
|
||||
|
||||
if (sections.length === 0 || mentionComplete) {
|
||||
@@ -230,7 +226,6 @@ export default class AtMention extends PureComponent {
|
||||
renderSectionHeader={this.renderSectionHeader}
|
||||
ItemSeparatorComponent={AutocompleteDivider}
|
||||
initialNumToRender={10}
|
||||
nestedScrollEnabled={nestedScrollEnabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user