Compare commits

..

75 Commits

Author SHA1 Message Date
Mattermost Build
c9e5da40ec Automated cherry pick of #3142 (#3143)
* Bump app build number to 221

* Update fastlane
2019-08-17 14:44:50 -04:00
Mattermost Build
51050e31a9 Fix Regression right buttons color on Android navbar when enable (#3141) 2019-08-17 09:07:56 -07:00
Mattermost Build
9c00f51058 Fix regression close/leave/archive channel redirect to channel screen on Android (#3140) 2019-08-17 09:07:46 -07:00
Mattermost Build
bfd50568cc Bump app build number to 220 (#3133) 2019-08-15 15:41:18 -07:00
Miguel Alatzar
5bef5c2da8 Remove renderHasGuestText (#3131) 2019-08-16 03:45:34 +05:30
Miguel Alatzar
b064312e70 Revert "Remove renderHasGuestText"
This reverts commit e82db7840f.
2019-08-15 15:08:25 -07:00
Miguel Alatzar
e82db7840f Remove renderHasGuestText 2019-08-15 15:06:10 -07:00
Mattermost Build
c4d8308802 Bump app build number to 219 (#3130) 2019-08-16 00:30:07 +05:30
Elias Nahum
b8b7d54185 Migrate to moment to fix timestamps based on timezone or local time (#3126) 2019-08-15 12:40:07 -04:00
Mattermost Build
8416525487 Fix loosing the server config when handleManagedConfig (#3128) 2019-08-15 08:27:52 -07:00
Mattermost Build
d7a85fe2de Automated cherry pick of #3123 (#3124)
* Set header buttons after mount

* Do the same for private channel and dm
2019-08-14 15:54:46 -04:00
Mattermost Build
c1957ac6a0 Apply setNavigatorStyles to all navigation components (#3122) 2019-08-15 02:06:57 +08:00
Mattermost Build
90dba40a92 Automated cherry pick of #3116 (#3120)
* Fix delete the root post on a thread to properly close the thread screen

* Fix unit test
2019-08-14 09:34:18 -07:00
Mattermost Build
f8efcf73b7 Bump app build number to 218 (#3119) 2019-08-14 16:45:19 +05:30
Mattermost Build
ff7548251b Automated cherry pick of #3115 (#3117)
* Await getSource

* Use server url from EphemeralStore

* Set currentServerUrl in getAppCredentials too
2019-08-14 16:11:55 +05:30
Harrison Healey
e03b72861c MM-17425 Disable Send button when sending post (1.22) (#3100)
* MM-17425 Disable Send button when sending post (1.22)

* Update snapshot and add tests
2019-08-14 16:02:48 +05:30
Mattermost Build
94aa1be435 Automated cherry pick of #3108 (#3113)
* MM-17693 Android fix close settings screen

* Fix unit tests

* fix snapshot
2019-08-13 10:34:07 -04:00
Mattermost Build
7bf3020490 Automated cherry pick of #3107 (#3112)
* MM-17692 execute callback after closing post options

* Fix unit tests

* Fix snapshot
2019-08-13 10:29:32 -04:00
Saturnino Abril
880cbd9f3d MM-15218 Open as alert dialog when interactive message is without element (#3088) 2019-08-13 22:23:21 +08:00
Mattermost Build
390c30af00 Do not track foreground notifications when notification is opened (#3111) 2019-08-13 10:18:55 -04:00
Elias Nahum
e3fd88b7fb translations PR 20190812 (#3109) 2019-08-13 10:15:37 -04:00
Mattermost Build
3ed078f4ce Automated cherry pick of #3087 (#3106)
* Add flex 1

* Add snapshot test

* Fix placeholder text color
2019-08-12 10:13:29 -04:00
Sudheer
e7042e4907 Bump app build number to 217 (#3104) 2019-08-10 23:05:59 +05:30
Mattermost Build
9ae69d9b43 MM-17630 add Navigation bar to terms of service modal (#3103) 2019-08-10 22:54:53 +05:30
Mattermost Build
eb314d714a MM-17612 Fix fetch periodic status updates when re-connecting (#3101) 2019-08-09 18:53:29 -04:00
Asaad Mahmood
cdd7a54ef4 MM-17484 - Updating loading rows (#3076)
* MM-17484 - Updating loading rows

* Updating test

* Updating mobile loader css

* Updating test

* Updating changes

* Updating tests
2019-08-09 14:40:07 -04:00
Mattermost Build
41ddb5cc1a Fix pop thread screen when deleting the root post (#3093) 2019-08-09 11:06:16 -04:00
Mattermost Build
aceef20257 Increase ReplyIcon dimensions (#3091) 2019-08-09 06:09:19 -07:00
Mattermost Build
a5f8b9bdcc MM-15218 allow interactive dialogs to have no elements (#3090) 2019-08-09 08:31:13 -04:00
Mattermost Build
18b7a3c0a1 Automated cherry pick of #3074 (#3085)
* MM-17562 scroll to new message line indicator on componentDidUpdate

* feedback review

* Cancel animation frame requests
2019-08-08 15:52:40 -04:00
Elias Nahum
7b75868101 Various fixes (#3078)
* MM-17588 Remove navigation component from stack

* MM-175986 Fix Clock Display Settings on iOS

* Fix markdown and team icon currentServerUrl

* Fix closing permalink logs out the user

* Fix file attachment document ref

* Fix applyTheme when changing a theme in the app

* Feedback review

* remove / when fetching the image on the markdown table on relative paths
2019-08-08 22:39:27 +08:00
Mattermost Build
24bd57ad3f Automated cherry pick of #3079 (#3083)
* Remove flex for horizontalRule

* Fix snapshot tests
2019-08-08 10:10:22 -04:00
Mattermost Build
b931733695 Fix theme thumbnail width (#3082) 2019-08-08 09:59:55 -04:00
Mattermost Build
088d375ff2 Automated cherry pick of #3075 (#3077)
* Update NOTICE.txt

* Update NOTICE.txt
2019-08-07 11:13:26 -04:00
Elias Nahum
4b06636d9b translations PR 20190805 (#3073) 2019-08-06 10:38:30 -04:00
Mattermost Build
05db1aaa71 Bump app build number to 216 (#3069) 2019-08-03 02:24:12 +05:30
Elias Nahum
e94dfb5389 MM-16752 Fix unhandled promise rejection TypeError state.websocket.lastConnectAt (#3039) 2019-08-02 16:13:55 -04:00
Elias Nahum
58b95e7609 translations PR 20190729 (#3055) 2019-08-02 15:23:23 -04:00
Elias Nahum
da911a2b34 MM-17424 Setting to enable/disable fixed sidebar (#3060) 2019-08-02 13:53:48 -04:00
Miguel Alatzar
d842d7881a Include archived teams filter change from mattermost-redux 2019-08-02 10:18:47 -07:00
Miguel Alatzar
98dc141ee3 Bump app build number to 215 (#3066) 2019-08-01 12:28:10 -04:00
Mattermost Build
8d0cb0663b Bump app build number to 214 (#3064) 2019-07-31 19:40:26 -04:00
Miguel Alatzar
612d284cbb Include null check on name from mattermost-redux 2019-07-31 13:31:28 -07:00
Elias Nahum
be727fec9e Bump app build number to 213 (#3058) 2019-07-31 09:33:23 -04:00
Mattermost Build
d0f059e1f9 Load Android WebView package instead of module (#3054) 2019-07-29 09:19:59 -04:00
Elias Nahum
df74521ff3 translations PR 20190724 (#3029) 2019-07-29 08:11:33 -04:00
Mattermost Build
eff961d109 Check if ackId is nil first (#3050) 2019-07-26 13:18:06 -04:00
Mattermost Build
59f3633d94 Fix test (#3052) 2019-07-26 10:13:15 -07:00
Mattermost Build
31bd391e56 Automated cherry pick of #3033 (#3047)
* Clear foreground notifications from AsyncStorage

* Remove unnecessary call to clearNotifications
2019-07-26 08:11:58 -04:00
Mattermost Build
810f73e3ad Automated cherry pick of #3026 (#3031)
* Parse source URI

* Revert "Parse source URI"

This reverts commit 1cf421c9b9.

* Pass imageUri instead of defaultSource to ProgressiveImage

* Parse source URI in ImageCacheManager
2019-07-25 11:05:13 -07:00
Mattermost Build
14421ba8e9 MM-17100 setCSRFFromCookie with subpath support (#3040) 2019-07-25 11:01:49 -07:00
Mattermost Build
5c4405278b MM-17120 Update fastlane image to a working one (#3045)
https://mattermost.atlassian.net/browse/MM-17120
2019-07-25 09:21:12 -04:00
Elias Nahum
067a5481ff Bump app build number to 212 (#3042) 2019-07-25 08:46:44 -04:00
Mattermost Build
c267b8dd13 Check initialIndex in callback (#3032) 2019-07-24 14:23:06 -07:00
Mattermost Build
ce325a4ab1 Automated cherry pick of #3001 (#3030)
* Include in-app notifications in iOS badge number

* Make foreground notifications key a const
2019-07-24 14:05:25 -07:00
Mattermost Build
1f8e853e41 Apply new theme to all navigation components (#3021) 2019-07-24 09:24:54 -04:00
Jesús Espino
8887319324 Revert Guest Accounts feature (#3024)
* Revert "Automated cherry pick of #3019 (#3020)"

This reverts commit a0b021d21d.

* Revert "Adding guest accounts feature (#2990) (#3015)"

This reverts commit 60030defb8.
2019-07-23 20:55:15 +02:00
Mattermost Build
e214039cee Call scrollToIndex only if flatListRef.current is not null (#3023) 2019-07-23 11:26:39 -07:00
Mattermost Build
0a93ec134c User popToRoot over resetToChannel (#3018) 2019-07-23 09:16:33 -07:00
Mattermost Build
a0b021d21d Automated cherry pick of #3019 (#3020)
* Fixing guest login

* Fixing eslint
2019-07-23 11:22:43 +02:00
Mattermost Build
75aedb8aa1 Automated cherry pick of #3014 (#3017)
* Add channel_id to failed push notification reply

* Fix typo
2019-07-22 15:40:40 -07:00
Jesús Espino
60030defb8 Adding guest accounts feature (#2990) (#3015)
* MM-15059: Restict the permissions for guests (#2791)

* MM-15057: Adding guest badge to identify guest users (#2774)

* MM-15057: Adding guest badge to identify guest users

* Adding Guest tags in the channel title

* Adding i18n translations

* Adding DM and GM guest warnings

* Fixing check-style

* Adding and fixing tests

* Adding i18n text

* Only showing the subtitle when there is enough space

* Addressing new design changes

* Fixing eslint

* Addressing PR comments

* Moving getChannelStats to the handleSelectChannel action

* Adding the guest info in android landscape channel headers

* simplified the guest warning text generation

* Fixing i18n

* Fixing leaving channel behavior for guests (#2989)

* Fixing leaving channel behavior for guests

* Fixing tests and adding a new one

* fixing typo

* Addressing PR comments

* Addressing PR comments

* Fixing tests
2019-07-22 23:46:15 +02:00
Mattermost Build
50cc6f827e Bump app build number to 211 (#3013) 2019-07-23 02:04:24 +05:30
Mattermost Build
ac11b7fec3 Bump app version number to 1.22.0 (#3011) 2019-07-23 00:40:08 +05:30
Elias Nahum
c9575b464d Update Fastlane (#3008) 2019-07-22 10:15:17 -04:00
Mattermost Build
a4284666a3 Automated cherry pick of #3002 (#3004)
* Set footerColor the same as navBarBackgroundColor

* Oops, forgot __snapshots__
2019-07-20 09:17:32 +08:00
Elias Nahum
4d5422e98b Bump app build number to 210 (#3000) 2019-07-19 16:53:40 -04:00
Elias Nahum
a3783b1bf5 MM-16829 Fix for websocket reconnects for android 2019-07-19 16:49:19 -04:00
Elias Nahum
c31ff56149 Bump Version to 1.21.1 and build number to 209 (#2995)
* Bump app build number to 209

* Bump app version number to 1.21.1
2019-07-18 18:43:48 -04:00
Mattermost Build
93498a3ab5 Fix Options modal on iOS (#2994) 2019-07-18 16:00:49 -04:00
Sudheer
6d10915aad MM-16829 Fix for sso with server subpaths (#2987) 2019-07-19 00:08:19 +05:30
Mattermost Build
79653ad814 MM-16815 Remove the failed posts banner on WS reconnect (#2988) 2019-07-18 11:38:37 -04:00
Ewe Tek Min
13bb83c11c [MM-14303] Able to copy channel header and purpose (#2923) 2019-07-18 11:24:02 -04:00
Mattermost Build
3f6e706fa1 Select server fix login options transition (#2986) 2019-07-18 11:00:29 -04:00
Mattermost Build
5492cd5e46 Automated cherry pick of #2961 (#2985)
* Enable emojis for profile icons from webhooks

Removes borders from profile icons when it is from a custom URL.

* feat: reapply border for all profile pictures and shrink emojis

* feat: remove border from emoji profile pictures

* feat: decide if post has emoji icon through props instead of url

* refactor: better checking of potentially undefined property

Co-Authored-By: Elias Nahum <nahumhbl@gmail.com>
2019-07-18 10:26:39 -04:00
1174 changed files with 37030 additions and 143399 deletions

View File

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

View File

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

View File

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

@@ -1,4 +1 @@
*.pbxproj -text
# specific for windows script files
*.bat text eol=crlf

13
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,14 @@
pipeline {
agent any
stages {
stage('Test') {
steps {
echo 'assets/base/config.json'
sh 'cat assets/base/config.json'
sh 'touch .podinstall'
sh 'make test || exit 1'
}
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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).]

View File

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

View File

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

View File

@@ -15,9 +15,7 @@ import com.android.build.OutputFile
* // the name of the generated asset file containing your JS bundle
* bundleAssetName: "index.android.bundle",
*
* // the entry file for bundle generation. If none specified and
* // "index.android.js" exists, it will be used. Otherwise "index.js" is
* // default. Can be overridden with ENTRY_FILE environment variable.
* // the entry file for bundle generation
* entryFile: "index.android.js",
*
* // whether to bundle JS and assets in debug mode
@@ -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)

View File

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

View File

@@ -1,68 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
* directory of this source tree.
*/
package com.rn;
import android.content.Context;
import com.facebook.flipper.android.AndroidFlipperClient;
import com.facebook.flipper.android.utils.FlipperUtils;
import com.facebook.flipper.core.FlipperClient;
import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
import com.facebook.flipper.plugins.fresco.FrescoFlipperPlugin;
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor;
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
import com.facebook.flipper.plugins.react.ReactFlipperPlugin;
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.network.NetworkingModule;
import okhttp3.OkHttpClient;
public class ReactNativeFlipper {
public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
if (FlipperUtils.shouldEnableFlipper(context)) {
final FlipperClient client = AndroidFlipperClient.getInstance(context);
client.addPlugin(new InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()));
client.addPlugin(new ReactFlipperPlugin());
client.addPlugin(new DatabasesFlipperPlugin(context));
client.addPlugin(new SharedPreferencesFlipperPlugin(context));
client.addPlugin(CrashReporterPlugin.getInstance());
NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin();
NetworkingModule.setCustomClientBuilder(
new NetworkingModule.CustomClientBuilder() {
@Override
public void apply(OkHttpClient.Builder builder) {
builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin));
}
});
client.addPlugin(networkFlipperPlugin);
client.start();
// Fresco Plugin needs to ensure that ImagePipelineFactory is initialized
// Hence we run if after all native modules have been initialized
ReactContext reactContext = reactInstanceManager.getCurrentReactContext();
if (reactContext == null) {
reactInstanceManager.addReactInstanceEventListener(
new ReactInstanceManager.ReactInstanceEventListener() {
@Override
public void onReactContextInitialized(ReactContext reactContext) {
reactInstanceManager.removeReactInstanceEventListener(this);
reactContext.runOnNativeModulesQueueThread(
new Runnable() {
@Override
public void run() {
client.addPlugin(new FrescoFlipperPlugin());
}
});
}
});
} else {
client.addPlugin(new FrescoFlipperPlugin());
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,8 @@ import android.content.Context;
import java.util.ArrayList;
import java.util.HashMap;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.WritableMap;
import com.oblador.keychain.KeychainModule;
import com.mattermost.react_native_interface.ResolvePromise;

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
package com.mattermost.rnbeta;
import android.net.Uri;
public interface RNEditTextOnPasteListener {
void onPaste(Uri itemUri);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.0.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-5.2.1-all.zip

24
android/gradlew vendored
View File

@@ -1,21 +1,5 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
@@ -44,7 +28,7 @@ APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
@@ -125,8 +109,8 @@ if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
@@ -185,4 +169,4 @@ if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"
exec "$JAVACMD" "$@"

160
android/gradlew.bat vendored
View File

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

@@ -0,0 +1,8 @@
keystore(
name = "debug",
properties = "debug.keystore.properties",
store = "debug.keystore",
visibility = [
"PUBLIC",
],
)

View File

@@ -0,0 +1,4 @@
key.store=debug.keystore
key.alias=androiddebugkey
key.store.password=android
key.alias.password=android

View File

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

View File

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

View File

@@ -1,186 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-import-assign */
import {Client4} from '@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();
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {removeChannelMember} from '@mm-redux/actions/channels';
import {removeChannelMember} from 'mattermost-redux/actions/channels';
export function handleRemoveChannelMembers(channelId, members) {
return async (dispatch, getState) => {

View File

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

View File

@@ -2,11 +2,11 @@
// See LICENSE.txt for license information.
import {handleSelectChannel, setChannelDisplayName} from './channel';
import {createChannel} from '@mm-redux/actions/channels';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {cleanUpUrlable} from '@mm-redux/utils/channel_utils';
import {generateId} from '@mm-redux/utils/helpers';
import {createChannel} from 'mattermost-redux/actions/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {cleanUpUrlable} from 'mattermost-redux/utils/channel_utils';
import {generateId} from 'mattermost-redux/utils/helpers';
export function generateChannelNameFromDisplayName(displayName) {
let name = cleanUpUrlable(displayName);

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {updateMe, setDefaultProfileImage} from '@mm-redux/actions/users';
import {updateMe, setDefaultProfileImage} from 'mattermost-redux/actions/users';
import {ViewTypes} from 'app/constants';

View File

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

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {FileTypes} from '@mm-redux/action_types';
import {FileTypes} from 'mattermost-redux/action_types';
import {ViewTypes} from 'app/constants';
import {buildFileUploadData, generateId} from 'app/utils/file';

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getDirectChannelName} from '@mm-redux/utils/channel_utils';
import {createDirectChannel, createGroupChannel} from '@mm-redux/actions/channels';
import {getProfilesByIds, getStatusesByIds} from '@mm-redux/actions/users';
import {getDirectChannelName} from 'mattermost-redux/utils/channel_utils';
import {createDirectChannel, createGroupChannel} from 'mattermost-redux/actions/channels';
import {getProfilesByIds, getStatusesByIds} from 'mattermost-redux/actions/users';
import {handleSelectChannel, toggleDMChannel, toggleGMChannel} from 'app/actions/views/channel';
export function makeDirectChannel(otherUserId, switchToChannel = true) {

View File

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

View File

@@ -1,188 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-import-assign */
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {Client4} from '@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);
});
});

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import {batchActions} from 'redux-batched-actions';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {GeneralTypes} from '@mm-redux/action_types';
import {GeneralTypes} from 'mattermost-redux/action_types';
import {ViewTypes} from 'app/constants';
@@ -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]);

View File

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

View File

@@ -1,68 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {getSessions} from '@mm-redux/actions/users';
import {Client4} from '@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,
},
});
}
};
}

View File

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

View File

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

View File

@@ -1,14 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {userTyping as wsUserTyping} from '@actions/websocket';
import {userTyping as wsUserTyping} from 'mattermost-redux/actions/websocket';
export function userTyping(channelId, rootId) {
return async (dispatch, getState) => {
const state = getState();
const {websocket} = state;
const {websocket} = getState();
if (websocket.connected) {
wsUserTyping(state, channelId, rootId);
wsUserTyping(channelId, rootId)(dispatch, getState);
}
};
}

View File

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

View File

@@ -4,14 +4,14 @@
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {UserTypes} from '@mm-redux/action_types';
import {General} from '@mm-redux/constants';
import {UserTypes} from 'mattermost-redux/action_types';
import {General} from 'mattermost-redux/constants';
import {setCurrentUserStatusOffline} from 'app/actions/views/user';
const mockStore = configureStore([thunk]);
jest.mock('@mm-redux/actions/users', () => ({
jest.mock('mattermost-redux/actions/users', () => ({
getStatus: (...args) => ({type: 'MOCK_GET_STATUS', args}),
getStatusesByIds: (...args) => ({type: 'MOCK_GET_STATUS_BY_IDS', args}),
startPeriodicStatusUpdates: () => ({type: 'MOCK_PERIODIC_STATUS_UPDATES'}),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 []}
/>
`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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