Compare commits

..

42 Commits

Author SHA1 Message Date
Mattermost Build
6394f89869 Automated cherry pick of #3927 (#3928)
* Bump app build number to 268

* Update server version in Android description

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-13 20:33:06 -07:00
Mattermost Build
31e5e0426e Call completionHandler in sendReply (#3926)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-13 20:27:24 -07:00
Mattermost Build
81292df787 Bump app build number to 267 (#3918)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-12 13:36:42 -07:00
Elias Nahum
882bc6b32b MM-22487 Fix race condition causing the user to logout (#3916) 2020-02-12 17:28:06 -03:00
Mattermost Build
5a6b389b5b Bump app build number to 266 (#3915)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-12 09:53:34 -03:00
Elias Nahum
b60b9985d6 translations PR 20200211 (#3912) 2020-02-12 09:42:46 -03:00
Mattermost Build
8e31c5c1b9 Bump app build number to 265 (#3910)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-10 16:08:34 -07:00
Mattermost Build
1e40d31b30 Re-enable ram-bundles (#3908)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-10 11:31:02 -07:00
Mattermost Build
fd1b8ce219 Dispatch loadConfigAndLicense on launchApp (#3906)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-10 13:50:58 -03:00
Mattermost Build
62c244cd72 Automated cherry pick of #3898 (#3905)
* Tighten up post draft UI

* Revert "MM-15307 Updated to use InteractionManager (#3666)"

This reverts commit e08155c81b.

* Address PR review comments

* Update snapshot test

* Don't return null if no files

* Fix progress text and padding issues

* Fixes per Matt's review

* Make linter happy

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-10 11:53:25 -03:00
Mattermost Build
af715828b6 MM-22253 Reduce unnecessary re-renders in channel switching (#3901)
Re-renders were occuring because of prop and state updates that created new object references, despite being of identical value. A key culprit was `postListHeight` in `PostList` which goes through a few calls to `handleContentSizeChange` when loading posts.

Also, "New Messages" divider line is a pure component now (via `memo`) to reduce unnecessary re-renders here too.

Co-authored-by: Amit Uttam <changingrainbows@gmail.com>
2020-02-07 14:16:49 -07:00
Mattermost Build
4b016a5272 Fix typo in error.message (#3899)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-07 09:54:16 -03:00
Mattermost Build
b7970c3a34 Remove ChannelPeek (#3897)
Introduced large number of unnecessary re-renders when opening channels, and ideally should not be a part of the channel switching/opening code path. Although this was discovered while trying to investigate an [Android-specific issue](https://mattermost.atlassian.net/browse/MM-22253), this extra code path made it difficult to see what Android is potentially doing differently than iOS.

Functionality originally introduced in #1203.

Conversation for removal is [here](https://community.mattermost.com/core/pl/hfcogf6pr7rw8k3ryq14c69c7e)
2020-02-06 22:46:09 -03:00
Mattermost Build
6806337b23 Automated cherry pick of #3892 (#3895)
* Check agains Permissions.RESULTS.GRANTED

* Fix file and image upload as well

* Fix permissions

* Make linter happy

* Use toHaveBeenCalledWith

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-06 12:14:44 -07:00
Mattermost Build
51e6b1e1aa Automated cherry pick of #3890 (#3893)
* Use dismissModal to close ChannelInfo screen

* Missing semicolon

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-05 17:22:43 -07:00
Mattermost Build
dc7f068b15 Bump app build number to 264 (#3889)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-04 14:57:19 -07:00
Mattermost Build
3daa365e44 Automated cherry pick of #3884 (#3887)
* Handle iOS reply action on native side

* Make linter happy

* Revert rn vector changes to .pbxproj

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-04 08:53:00 -07:00
Mattermost Build
5f0df6eb49 MM-22165 Fix channel sidebar close gesture (#3882)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-01 13:15:32 -03:00
Mattermost Build
ccc9e7c75c Automated cherry pick of #3877 (#3878)
* Bump app build number to 263

* Update ESR

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-30 10:39:44 -03:00
Mattermost Build
61c9110d41 Automated cherry pick of #3874 (#3876)
* Import from semver/preload

* Add unit tests

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-30 09:59:53 -03:00
Mattermost Build
bf73bf4ecc Bump app build number to 262 (#3872)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-28 15:47:53 -07:00
Mattermost Build
c04d2e6040 Automated cherry pick of #3865 (#3870)
* Sort emojis in EmojiPicker

* Pass search term to compareEmojis

* Sort emojis that include search term first

* Fix sorting

* Handle compareEmojis without search term

* Return doDefaultComparison

* Check includes only if needed

* Make linter happy

* Use doDefaultComparison

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-28 15:09:04 -07:00
Mattermost Build
1e0ead398f Automated cherry pick of #3860 (#3868)
* Fix iOS photo/camera denied permissions

* Add unit test and rename files

* Request for permission if returns denied

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-28 16:56:18 -03:00
Mattermost Build
352a103b48 Handle links with no server URL provided (#3867)
Default to current server or site URL.

Example case: `<jump to convo>` links generated from autolink plugin have their server/site URL stripped, and it is assumed that generated links are relative to the current server.

Conversation: https://community.mattermost.com/core/pl/78j4a7ozupbci8qxwx1sczc1ua
2020-01-28 09:18:58 -03:00
Mattermost Build
1f3ffee26f iOS Slide open main sidebar from anywhere (#3866)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-27 19:57:46 -07:00
Mattermost Build
8be5649ee6 MM-21961 Fix Incorrect Timestamp on Android Mobile App (#3864)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-27 13:00:58 -07:00
Mattermost Build
1d75287892 Automated cherry pick of #3845 (#3863)
* Don't use localPath when it's the share extension cache dir

* Move android pasted images to cache image folder

* Use Files.move instead of FileInput / FileOutput stream

* Remove commented code and not needed imports

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-27 12:59:36 -07:00
Mattermost Build
96e017e9eb Handle com.compuserve.gif type with dataForPasteboardType (#3857)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-24 10:45:29 -07:00
Mattermost Build
44c3910ce6 Automated cherry pick of #3848 (#3849)
* Bump app build number to 261

* Bump app version number to 1.28.0

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-23 13:38:09 -03:00
Mattermost Build
23db3b75e2 Temporary replace Hermes with V8 (#3850)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-23 09:30:12 -07:00
Mattermost Build
dddcbefefe Call Linking.getInitialURL() in launchApp (#3844)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-22 11:45:19 -07:00
Mattermost Build
a7dc68b40b Fix Android unsigned releases to work with Hermes (#3842) 2020-01-21 15:50:27 -03:00
Andre Vasconcelos
0b81a9b4e0 Fixing post draft style to comply with design specs (#3818)
* Polishing post draft to comply with design specs

* changed maxHeight in landscape mode, fixed icon sizes

- Refactored code so that post draft icon sizes are taken from same constant value

- Set maxHeight value in landscape mode to be smaller (tests pending)

- Removed repeated styles for button wrappers (passing them down as props to child components)

- Increased size of image attachment remote icon, and increased tappable area

* Removing repeated logic for file upload

* Fixing failed snapshot tests / style checks

* Fixing file upload remove icon to have 64% opacity

* post draft UX/UI improvements

* Fix input box extra spacing

* input box line height and attachment border

* Animate to original state even if error is showing

* Fix permissions

* Improve attachment error animation

* Fix iOS post input height

* Update snapshots
2020-01-21 15:25:28 -03:00
Mattermost Build
e05207412f MM-21723 Handle deep link errors to inaccessible teams, channels & permalinks (#3840)
* Consolidated error handling for a user's reachable teams.
* Consolidated error handling for a user's reachable channels.
2020-01-21 14:47:10 -03:00
Mattermost Build
3b909101f2 MM-21892 Fix TypeError cause by mm-redux#1006 (#3839)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-21 13:30:57 -03:00
Mattermost Build
96f5ae009d Bump app version number to 1.27.1 (#3834)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-20 12:33:32 -07:00
Mattermost Build
a44032f0fb Bump app build number to 260 (#3831)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-20 12:24:45 -07:00
Mattermost Build
9dd5a1c2ed Set default app scheme to mattermost (#3828)
Was originally set to `mattermost-beta` as per #3767 .
2020-01-20 16:18:55 -03:00
Elias Nahum
0c42c0d976 Deps update (#3806)
* Dependecy updates

* Update dependencies
2020-01-20 13:22:07 -03:00
Mattermost Build
e8398cb880 MM-21634 Fix keyboard glitch when returning to channel screen from the code screen (#3824)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-20 12:53:34 -03:00
Mattermost Build
4d83724092 Automated cherry pick of #3819 (#3821)
* Dispatch loadConfigAndLicense on successful login

* Emit server version changed event

* Make linter happy

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-20 08:30:59 -07:00
Mattermost Build
8f8d32ff7a Automated cherry pick of #3793 (#3820)
* MM-21632: fix toggling interactive dialog boolean

My fix for MM-17519 broke the /rendering/ of the boolean toggle, but not
the underlying interactive dialog state (and the thrust of the original
issue).

* MM-21683: fix handling of boolean defaults

* unit tests

Co-authored-by: Jesse Hallam <jesse.hallam@gmail.com>
2020-01-20 21:33:31 +08:00
956 changed files with 22900 additions and 102816 deletions

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

@@ -21,7 +21,7 @@ node_modules/warning/.*
[include]
[libs]
node_modules/react-native/interface.js
node_modules/react-native/Libraries/react-native/react-native-interface.js
node_modules/react-native/flow/
[options]
@@ -36,8 +36,9 @@ module.file_ext=.ios.js
munge_underscores=true
module.name_mapper='^react-native$' -> '<PROJECT_ROOT>/node_modules/react-native/Libraries/react-native/react-native-implementation'
module.name_mapper='^react-native/\(.*\)$' -> '<PROJECT_ROOT>/node_modules/react-native/\1'
module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> '<PROJECT_ROOT>/node_modules/react-native/Libraries/Image/RelativeImageStub'
module.name_mapper='^[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> '<PROJECT_ROOT>/node_modules/react-native/Libraries/Image/RelativeImageStub'
suppress_type=$FlowIssue
suppress_type=$FlowFixMe
@@ -71,4 +72,4 @@ untyped-import
untyped-type-import
[version]
^0.113.0
^0.105.0

3
.gitattributes vendored
View File

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

1
.gitignore vendored
View File

@@ -87,7 +87,6 @@ ios/sentry.properties
# Testing
.nyc_output
coverage
.tmp
# Bundle artifact
*.jsbundle

View File

@@ -1,190 +1,5 @@
# 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

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

1127
NOTICE.txt

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ Otherwise, link the JIRA ticket.
Place an '[x]' (no spaces) in all applicable fields. Please remove unrelated fields.
-->
- [ ] Added or updated unit tests (required for all new features)
- [ ] All new/modified APIs include changes to [mattermost-redux](https://github.com/mattermost/mattermost-redux) (please link)
- [ ] Has UI changes
- [ ] Includes text changes and localization file updates

View File

@@ -1,7 +1,7 @@
# Mattermost Mobile
- **Minimum Server versions:** Current ESR version (5.19)
- **Supported iOS versions:** 11+
- **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

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
@@ -132,9 +130,9 @@ android {
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", "reactNative60"
versionCode 268
versionName "1.28.0"
multiDexEnabled = true
ndk {
abiFilters 'armeabi-v7a','arm64-v8a','x86','x86_64'
@@ -197,10 +195,6 @@ android {
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"
}
}
@@ -230,23 +224,6 @@ configurations.all {
}
dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
//noinspection GradleDynamicVersio
implementation "com.facebook.react:react-native:+" // From node_modules
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
exclude group:'com.facebook.fbjni'
}
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
exclude group:'com.facebook.flipper'
}
debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") {
exclude group:'com.facebook.flipper'
}
if (enableHermes) {
def hermesPath = "../../node_modules/hermes-engine/android/";
debugImplementation files(hermesPath + "hermes-debug.aar")
@@ -256,13 +233,40 @@ dependencies {
implementation jscFlavor
}
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "com.facebook.react:react-native:+" // From node_modules
implementation 'androidx.appcompat:appcompat:1.0.0'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation project(':reactnativenotifications')
implementation 'com.google.firebase:firebase-messaging:17.3.4'
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 project(':react-native-cookies')
implementation project(':react-native-linear-gradient')
implementation project(':react-native-vector-icons')
implementation project(':react-native-svg')
implementation project(':react-native-local-auth')
implementation project(':jail-monkey')
implementation project(':react-native-youtube')
implementation project(':react-native-exception-handler')
implementation project(':rn-fetch-blob')
implementation project(':react-native-webview')
implementation project(':react-native-gesture-handler')
implementation project(':@react-native-community_async-storage')
implementation project(':@react-native-community_netinfo')
implementation project(':@sentry_react-native')
implementation project(':react-native-android-open-settings')
implementation project(':react-native-haptic-feedback')
implementation project(':react-native-permissions')
implementation project(':react-native-fast-image')
// For animated GIF support
implementation 'com.facebook.fresco:fresco:2.0.0'
implementation 'com.facebook.fresco:animated-gif:2.0.0'
@@ -279,4 +283,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

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

@@ -28,7 +28,7 @@
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask">
<intent-filter>

View File

@@ -2,49 +2,13 @@ package com.mattermost.rnbeta;
import android.os.Bundle;
import androidx.annotation.Nullable;
import android.view.KeyEvent;
import android.content.res.Configuration;
import com.reactnativenavigation.NavigationActivity;
import com.github.emilioicai.hwkeyboardevent.HWKeyboardEventModule;
public class MainActivity extends NavigationActivity {
private boolean HWKeyboardConnected = false;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.launch_screen);
setHWKeyboardConnected();
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO) {
HWKeyboardConnected = true;
} else if (newConfig.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_YES) {
HWKeyboardConnected = false;
}
}
/*
https://mattermost.atlassian.net/browse/MM-10601
Required by react-native-hw-keyboard-event
(https://github.com/emilioicai/react-native-hw-keyboard-event)
*/
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (HWKeyboardConnected && event.getKeyCode() == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_UP) {
String keyPressed = event.isShiftPressed() ? "shift-enter" : "enter";
HWKeyboardEventModule.getInstance().keyPressed(keyPressed);
return true;
}
return super.dispatchKeyEvent(event);
};
private void setHWKeyboardConnected() {
HWKeyboardConnected = getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY;
}
}

View File

@@ -1,21 +1,49 @@
package com.mattermost.rnbeta;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.content.Context;
import android.content.RestrictionsManager;
import android.os.Bundle;
import android.util.Log;
import java.lang.reflect.InvocationTargetException;
import java.io.File;
import java.util.HashMap;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import com.mattermost.share.RealPathUtil;
import com.mattermost.share.ShareModule;
import com.wix.reactnativenotifications.RNNotificationsPackage;
import com.learnium.RNDeviceInfo.RNDeviceModule;
import com.imagepicker.ImagePickerModule;
import com.psykar.cookiemanager.CookieManagerModule;
import com.oblador.vectoricons.VectorIconsModule;
import com.wix.reactnativenotifications.RNNotificationsModule;
import io.tradle.react.LocalAuthModule;
import com.gantix.JailMonkey.JailMonkeyModule;
import com.RNFetchBlob.RNFetchBlob;
import com.masteratul.exceptionhandler.ReactNativeExceptionHandlerModule;
import com.inprogress.reactnativeyoutube.YouTubeStandaloneModule;
import com.philipphecht.RNDocViewerModule;
import io.github.elyx0.reactnativedocumentpicker.DocumentPickerModule;
import com.oblador.keychain.KeychainModule;
import com.reactnativecommunity.asyncstorage.AsyncStorageModule;
import com.reactnativecommunity.netinfo.NetInfoModule;
import com.reactnativecommunity.webview.RNCWebViewPackage;
import io.sentry.RNSentryModule;
import com.dylanvann.fastimage.FastImageViewPackage;
import com.levelasquez.androidopensettings.AndroidOpenSettings;
import com.mkuczera.RNReactNativeHapticFeedbackModule;
import com.reactnativecommunity.rnpermissions.RNPermissionsModule;
import com.reactnativecommunity.webview.RNCWebViewPackage;
import com.brentvatne.react.ReactVideoPackage;
import com.BV.LinearGradient.LinearGradientPackage;
import com.horcrux.svg.SvgPackage;
import com.swmansion.gesturehandler.react.RNGestureHandlerPackage;
import com.reactnativenavigation.NavigationApplication;
import com.reactnativenavigation.react.NavigationReactNativeHost;
import com.reactnativenavigation.react.ReactGateway;
import com.wix.reactnativenotifications.core.notification.INotificationsApplication;
import com.wix.reactnativenotifications.core.notification.IPushNotification;
import com.wix.reactnativenotifications.core.notificationdrawer.IPushNotificationsDrawer;
@@ -24,8 +52,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 +67,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 +84,78 @@ public class MainApplication extends NavigationApplication implements INotificat
private Bundle mManagedConfig = null;
private final ReactNativeHost mReactNativeHost =
new ReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}
@Override
protected ReactGateway createReactGateway() {
ReactNativeHost host = new NavigationReactNativeHost(this, isDebug(), createAdditionalReactPackages()) {
@Override
protected String getJSMainModuleName() {
return "index";
}
};
return new ReactGateway(this, isDebug(), host);
}
@Override
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage());
packages.add(new RNNotificationsPackage(MainApplication.this));
packages.add(new RNPasteableTextInputPackage());
packages.add(
new TurboReactPackage() {
@Override
public boolean isDebug() {
return BuildConfig.DEBUG;
}
@NonNull
@Override
public List<ReactPackage> createAdditionalReactPackages() {
// Add the packages you require here.
// No need to add RnnPackage and MainReactPackage
return Arrays.<ReactPackage>asList(
new TurboReactPackage() {
@Override
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
switch (name) {
case "MattermostManaged":
return MattermostManagedModule.getInstance(reactContext);
case "MattermostShare":
return new ShareModule(instance, reactContext);
case "RNDeviceInfo":
return new RNDeviceModule(reactContext, false);
case "ImagePickerManager":
return new ImagePickerModule(reactContext, R.style.DefaultExplainingPermissionsTheme);
case "RNCookieManagerAndroid":
return new CookieManagerModule(reactContext);
case "RNVectorIconsModule":
return new VectorIconsModule(reactContext);
case "WixRNNotifications":
return new RNNotificationsModule(instance, reactContext);
case "RNLocalAuth":
return new LocalAuthModule(reactContext);
case "JailMonkey":
return new JailMonkeyModule(reactContext, false);
case "RNFetchBlob":
return new RNFetchBlob(reactContext);
case "MattermostManaged":
return MattermostManagedModule.getInstance(reactContext);
case "NotificationPreferences":
return NotificationPreferencesModule.getInstance(instance, reactContext);
case "RNTextInputReset":
return new RNTextInputResetModule(reactContext);
case "ReactNativeExceptionHandler":
return new ReactNativeExceptionHandlerModule(reactContext);
case "YouTubeStandaloneModule":
return new YouTubeStandaloneModule(reactContext);
case "RNDocViewer":
return new RNDocViewerModule(reactContext);
case "RNDocumentPicker":
return new DocumentPickerModule(reactContext);
case "RNKeychainManager":
return new KeychainModule(reactContext);
case "RNSentry":
return new RNSentryModule(reactContext);
case AsyncStorageModule.NAME:
return new AsyncStorageModule(reactContext);
case NetInfoModule.NAME:
return new NetInfoModule(reactContext);
case "RNAndroidOpenSettings":
return new AndroidOpenSettings(reactContext);
case "RNReactNativeHapticFeedbackModule":
return new RNReactNativeHapticFeedbackModule(reactContext);
case "RNPermissions":
return new RNPermissionsModule(reactContext);
default:
throw new IllegalArgumentException("Could not find module " + name);
}
@@ -96,28 +168,42 @@ private final ReactNativeHost mReactNativeHost =
public Map<String, ReactModuleInfo> getReactModuleInfos() {
Map<String, ReactModuleInfo> map = new HashMap<>();
map.put("MattermostManaged", new ReactModuleInfo("MattermostManaged", "com.mattermost.rnbeta.MattermostManagedModule", false, false, false, false, false));
map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false));
map.put("NotificationPreferences", new ReactModuleInfo("NotificationPreferences", "com.mattermost.rnbeta.NotificationPreferencesModule", false, false, false, false, false));
map.put("RNTextInputReset", new ReactModuleInfo("RNTextInputReset", "com.mattermost.rnbeta.RNTextInputResetModule", false, false, false, false, false));
map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false));
map.put("RNDeviceInfo", new ReactModuleInfo("RNDeviceInfo", "com.learnium.RNDeviceInfo.RNDeviceModule", false, false, true, false, false));
map.put("ImagePickerManager", new ReactModuleInfo("ImagePickerManager", "com.imagepicker.ImagePickerModule", false, false, false, false, false));
map.put("RNCookieManagerAndroid", new ReactModuleInfo("RNCookieManagerAndroid", "com.psykar.cookiemanager.CookieManagerModule", false, false, false, false, false));
map.put("RNVectorIconsModule", new ReactModuleInfo("RNVectorIconsModule", "com.oblador.vectoricons.VectorIconsModule", false, false, false, false, false));
map.put("WixRNNotifications", new ReactModuleInfo("WixRNNotifications", "com.wix.reactnativenotifications.RNNotificationsModule", false, false, false, false, false));
map.put("RNLocalAuth", new ReactModuleInfo("RNLocalAuth", "io.tradle.react.LocalAuthModule", false, false, false, false, false));
map.put("JailMonkey", new ReactModuleInfo("JailMonkey", "com.gantix.JailMonkey.JailMonkeyModule", false, false, true, false, false));
map.put("RNFetchBlob", new ReactModuleInfo("RNFetchBlob", "com.RNFetchBlob.RNFetchBlob", false, false, true, false, false));
map.put("ReactNativeExceptionHandler", new ReactModuleInfo("ReactNativeExceptionHandler", "com.masteratul.exceptionhandler.ReactNativeExceptionHandlerModule", false, false, false, false, false));
map.put("YouTubeStandaloneModule", new ReactModuleInfo("YouTubeStandaloneModule", "com.inprogress.reactnativeyoutube.YouTubeStandaloneModule", false, false, false, false, false));
map.put("RNDocViewer", new ReactModuleInfo("RNDocViewer", "com.philipphecht.RNDocViewerModule", false, false, false, false, false));
map.put("RNDocumentPicker", new ReactModuleInfo("RNDocumentPicker", "io.github.elyx0.reactnativedocumentpicker.DocumentPickerModule", false, false, false, false, false));
map.put("RNKeychainManager", new ReactModuleInfo("RNKeychainManager", "com.oblador.keychain.KeychainModule", false, false, true, false, false));
map.put("RNSentry", new ReactModuleInfo("RNSentry", "com.sentry.RNSentryModule", false, false, true, false, false));
map.put(AsyncStorageModule.NAME, new ReactModuleInfo(AsyncStorageModule.NAME, "com.reactnativecommunity.asyncstorage.AsyncStorageModule", false, false, false, false, false));
map.put(NetInfoModule.NAME, new ReactModuleInfo(NetInfoModule.NAME, "com.reactnativecommunity.netinfo.NetInfoModule", false, false, false, false, false));
map.put("RNAndroidOpenSettings", new ReactModuleInfo("RNAndroidOpenSettings", "com.levelasquez.androidopensettings.AndroidOpenSettings", false, false, false, false, false));
map.put("RNReactNativeHapticFeedbackModule", new ReactModuleInfo("RNReactNativeHapticFeedback", "com.mkuczera.RNReactNativeHapticFeedbackModule", false, false, false, false, false));
map.put("RNPermissions", new ReactModuleInfo("RNPermissions", "com.reactnativecommunity.rnpermissions.RNPermissionsModule", false, false, false, false, false));
return map;
}
};
}
}
);
return packages;
}
@Override
protected String getJSMainModuleName() {
return "index";
}
};
@Override
public ReactNativeHost getReactNativeHost() {
return mReactNativeHost;
},
new FastImageViewPackage(),
new RNCWebViewPackage(),
new SvgPackage(),
new LinearGradientPackage(),
new ReactVideoPackage(),
new RNGestureHandlerPackage(),
new RNPasteableTextInputPackage()
);
}
@Override
@@ -131,7 +217,6 @@ private final ReactNativeHost mReactNativeHost =
Log.i("ReactNative", "Cleaning temp cache " + tempFolder.getAbsolutePath());
SoLoader.init(this, /* native exopackage */ false);
initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
// Uncomment to listen to react markers for build that has telemetry enabled
// addReactMarkerListener();
@@ -154,11 +239,14 @@ private final ReactNativeHost mReactNativeHost =
}
public ReactContext getRunningReactContext() {
if (mReactNativeHost == null) {
final ReactGateway reactGateway = getReactGateway();
if (reactGateway == null) {
return null;
}
return mReactNativeHost
return reactGateway
.getReactNativeHost()
.getReactInstanceManager()
.getCurrentReactContext();
}
@@ -226,35 +314,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

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

@@ -63,6 +63,7 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
String token = map.getString("password");
String serverUrl = map.getString("service");
Log.i("ReactNative", String.format("URL=%s", serverUrl));
replyToMessage(serverUrl, token, notificationId, message);
}
}
@@ -87,16 +88,12 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
String json = buildReplyPost(channelId, rootId, message.toString());
RequestBody body = RequestBody.create(JSON, json);
String postsEndpoint = "/api/v4/posts?set_online=false";
String url = String.format("%s%s", serverUrl.replaceAll("/$", ""), postsEndpoint);
Log.i("ReactNative", String.format("Reply URL=%s", url));
Request request = new Request.Builder()
.header("Authorization", String.format("Bearer %s", token))
.header("Content-Type", "application/json")
.url(url)
.post(body)
.build();
.header("Authorization", String.format("Bearer %s", token))
.header("Content-Type", "application/json")
.url(String.format("%s/api/v4/posts", serverUrl.replaceAll("/$", "")))
.post(body)
.build();
client.newCall(request).enqueue(new okhttp3.Callback() {
@Override

View File

@@ -19,8 +19,6 @@ 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;
@@ -135,18 +133,10 @@ public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteList
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);
String dest = ctx.getCacheDir().getAbsolutePath() + "/Images/" + fileName;
try {
if (!folder.exists()) {
folder.mkdirs();
}
Files.move(Paths.get(src), Paths.get(dest));
} catch (FileAlreadyExistsException fileError) {
// Do nothing and return dest path
} catch (Exception err) {
return null;
}

View File

@@ -71,12 +71,8 @@ public class RNPasteableTextInputManager extends ReactTextInputManager {
@Nullable
@Override
public Map<String, Object> getExportedCustomBubblingEventTypeConstants() {
Map<String, Object> map = super.getExportedCustomBubblingEventTypeConstants();
map.put(
"onPaste",
MapBuilder.of(
"phasedRegistrationNames",
MapBuilder.of("bubbled", "onPaste")));
Map map = super.getExportedViewConstants();
map.put("onPaste", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onPaste")));
return map;
}
}

View File

@@ -26,8 +26,6 @@ import com.mattermost.react_native_interface.ResolvePromise;
public class ReceiptDelivery {
static final String CURRENT_SERVER_URL = "@currentServerUrl";
private static final int[] FIBONACCI_BACKOFFS = new int[] { 0, 1, 2, 3, 5, 8 };
public static void send(Context context, final String ackId, final String postId, final String type, final boolean isIdLoaded, ResolvePromise promise) {
final ReactApplicationContext reactApplicationContext = new ReactApplicationContext(context);
@@ -97,41 +95,26 @@ public class ReceiptDelivery {
.post(body)
.build();
makeServerRequest(client, request, isIdLoaded, 0, promise);
}
}
private static void makeServerRequest(OkHttpClient client, Request request, Boolean isIdLoaded, int reRequestCount, ResolvePromise promise) {
try {
Response response = client.newCall(request).execute();
String responseBody = response.body().string();
if (response.code() != 200) {
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));
try {
Response response = client.newCall(request).execute();
String responseBody = response.body().string();
if (response.code() != 200 || !isIdLoaded) {
throw new Exception(responseBody);
}
}
promise.resolve(bundle);
} catch (Exception e) {
Log.e("ReactNative", "Receipt delivery failed to send");
if (isIdLoaded) {
try {
reRequestCount++;
if (reRequestCount < FIBONACCI_BACKOFFS.length) {
Log.i("ReactNative", "Retry attempt " + reRequestCount + " with backoff delay: " + FIBONACCI_BACKOFFS[reRequestCount] + " seconds");
Thread.sleep(FIBONACCI_BACKOFFS[reRequestCount] * 1000);
makeServerRequest(client, request, isIdLoaded, reRequestCount, promise);
JSONObject jsonResponse = new JSONObject(responseBody);
Bundle bundle = new Bundle();
String keys[] = new String[] {"post_id", "category", "message", "team_id", "channel_id", "channel_name", "type", "sender_id", "sender_name", "version"};
for (int i = 0; i < keys.length; i++) {
String key = keys[i];
if (jsonResponse.has(key)) {
bundle.putString(key, jsonResponse.getString(key));
}
} catch(InterruptedException ie) {}
}
promise.resolve(bundle);
} catch (Exception e) {
Log.e("ReactNative", "Receipt delivery failed to send");
promise.reject("Receipt delivery failure", e.toString());
}
promise.reject("Receipt delivery failure", e.toString());
}
}
}

View File

@@ -194,12 +194,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

@@ -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.4.2'
classpath 'com.google.gms:google-services:4.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
@@ -61,10 +57,7 @@ allprojects {
url("$rootDir/../node_modules/v8-android/dist")
}
maven {
url "https://www.jitpack.io"
}
maven {
url ("https://dl.bintray.com/rudderstack/rudderstack")
url "https://jitpack.io"
}
}
}

View File

@@ -21,13 +21,5 @@ org.gradle.jvmargs=-Xmx2048M
#android.enableAapt2=false
#android.useDeprecatedNdk=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.33.1
android.enableJetifier=true

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.5-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

View File

@@ -1,5 +1,55 @@
rootProject.name = 'Mattermost'
include ':@sentry_react-native'
project(':@sentry_react-native').projectDir = new File(rootProject.projectDir, '../node_modules/@sentry/react-native/android')
include ':react-native-android-open-settings'
project(':react-native-android-open-settings').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-android-open-settings/android')
include ':react-native-permissions'
project(':react-native-permissions').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-permissions/android')
include ':react-native-fast-image'
project(':react-native-fast-image').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fast-image/android')
include ':react-native-haptic-feedback'
project(':react-native-haptic-feedback').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-haptic-feedback/android')
include ':react-native-gesture-handler'
project(':react-native-gesture-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-gesture-handler/android')
include ':react-native-document-picker'
project(':react-native-document-picker').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-document-picker/android')
include ':react-native-keychain'
project(':react-native-keychain').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-keychain/android')
include ':react-native-doc-viewer'
project(':react-native-doc-viewer').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-doc-viewer/android')
include ':react-native-video'
project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android')
include ':react-native-youtube'
project(':react-native-youtube').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-youtube/android')
include ':react-native-exception-handler'
project(':react-native-exception-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-exception-handler/android')
include ':rn-fetch-blob'
project(':rn-fetch-blob').projectDir = new File(rootProject.projectDir, '../node_modules/rn-fetch-blob/android')
include ':jail-monkey'
project(':jail-monkey').projectDir = new File(rootProject.projectDir, '../node_modules/jail-monkey/android')
include ':react-native-local-auth'
project(':react-native-local-auth').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-local-auth/android')
include ':react-native-navigation'
project(':react-native-navigation').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-navigation/lib/android/app/')
include ':react-native-image-picker'
project(':react-native-image-picker').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-image-picker/android')
include ':react-native-device-info'
project(':react-native-device-info').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-device-info/android')
include ':react-native-cookies'
project(':react-native-cookies').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-cookies/android')
include ':react-native-vector-icons'
project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android')
include ':reactnativenotifications'
project(':reactnativenotifications').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-notifications/android/app')
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
include ':app'
include ':react-native-svg'
project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android')
include ':react-native-linear-gradient'
project(':react-native-linear-gradient').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-linear-gradient/android')
include ':react-native-webview'
project(':react-native-webview').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-webview/android')
include ':@react-native-community_async-storage'
project(':@react-native-community_async-storage').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/async-storage/android')
include ':@react-native-community_netinfo'
project(':@react-native-community_netinfo').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/netinfo/android')

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

View File

@@ -1,20 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Keyboard, Platform} from 'react-native';
import {Platform} from 'react-native';
import {Navigation} from 'react-native-navigation';
import merge from 'deepmerge';
import {Preferences} from '@mm-redux/constants';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import EphemeralStore from '@store/ephemeral_store';
import Store from '@store/store';
const CHANNEL_SCREEN = 'Channel';
import store from 'app/store';
import EphemeralStore from 'app/store/ephemeral_store';
function getThemeFromState() {
const state = Store.redux?.getState() || {};
const state = store.getState();
return getTheme(state);
}
@@ -22,85 +20,55 @@ function getThemeFromState() {
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,
stack: {
children: [{
component: {
name: 'Channel',
passProps,
options: {
layout: {
backgroundColor: 'transparent',
},
statusBar: {
visible: true,
},
topBar: {
visible: false,
height: 0,
backButton: {
color: theme.sidebarHeaderTextColor,
title: '',
},
background: {
color: theme.sidebarHeaderBg,
},
title: {
color: theme.sidebarHeaderTextColor,
},
},
},
},
}],
},
},
});
}
export function resetToSelectServer(allowOtherServers) {
const theme = Preferences.THEMES.default;
const theme = getThemeFromState();
Navigation.setRoot({
root: {
stack: {
children: [{
component: {
id: 'SelectServer',
name: 'SelectServer',
passProps: {
allowOtherServers,
},
options: {
layout: {
backgroundColor: theme.centerChannelBg,
componentBackgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
},
@@ -127,7 +95,7 @@ export function resetToTeams(name, title, passProps = {}, options = {}) {
const theme = getThemeFromState();
const defaultOptions = {
layout: {
componentBackgroundColor: theme.centerChannelBg,
backgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
@@ -153,7 +121,6 @@ export function resetToTeams(name, title, passProps = {}, options = {}) {
stack: {
children: [{
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -169,12 +136,7 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
const componentId = EphemeralStore.getNavigationTopComponentId();
const defaultOptions = {
layout: {
componentBackgroundColor: theme.centerChannelBg,
},
popGesture: true,
sideMenu: {
left: {enabled: false},
right: {enabled: false},
backgroundColor: theme.centerChannelBg,
},
topBar: {
animate: true,
@@ -195,7 +157,6 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
Navigation.push(componentId, {
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -226,9 +187,8 @@ export async function popToRoot() {
export function showModal(name, title, passProps = {}, options = {}) {
const theme = getThemeFromState();
const defaultOptions = {
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
layout: {
componentBackgroundColor: theme.centerChannelBg,
backgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
@@ -256,7 +216,6 @@ export function showModal(name, title, passProps = {}, options = {}) {
stack: {
children: [{
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -273,7 +232,6 @@ export function showModalOverCurrentContext(name, passProps = {}, options = {})
modalPresentationStyle: 'overCurrentContext',
layout: {
backgroundColor: 'transparent',
componentBackgroundColor: 'transparent',
},
topBar: {
visible: false,
@@ -281,7 +239,6 @@ export function showModalOverCurrentContext(name, passProps = {}, options = {})
},
animations: {
showModal: {
waitForRender: true,
enabled: animationsEnabled,
alpha: {
from: 0,
@@ -338,6 +295,23 @@ export async function dismissAllModals(options = {}) {
}
}
export function peek(name, passProps = {}, options = {}) {
const componentId = EphemeralStore.getNavigationTopComponentId();
const defaultOptions = {
preview: {
commit: false,
},
};
Navigation.push(componentId, {
component: {
name,
passProps,
options: merge(defaultOptions, options),
},
});
}
export function setButtons(componentId, buttons = {leftButtons: [], rightButtons: []}) {
const options = {
topBar: {
@@ -354,10 +328,6 @@ export function mergeNavigationOptions(componentId, options) {
export function showOverlay(name, passProps, options = {}) {
const defaultOptions = {
layout: {
backgroundColor: 'transparent',
componentBackgroundColor: 'transparent',
},
overlay: {
interceptTouchOutside: false,
},
@@ -380,69 +350,3 @@ export async function dismissOverlay(componentId) {
// this componentId to dismiss. We'll do nothing in this case.
}
}
export function openMainSideMenu() {
if (Platform.OS === 'ios') {
return;
}
const componentId = EphemeralStore.getNavigationTopComponentId();
Keyboard.dismiss();
Navigation.mergeOptions(componentId, {
sideMenu: {
left: {visible: true},
},
});
}
export function closeMainSideMenu() {
if (Platform.OS === 'ios') {
return;
}
Keyboard.dismiss();
Navigation.mergeOptions(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

@@ -3,27 +3,20 @@
import {Platform} from 'react-native';
import {Navigation} from 'react-native-navigation';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import merge from 'deepmerge';
import * as NavigationActions from '@actions/navigation';
import Preferences from '@mm-redux/constants/preferences';
import EphemeralStore from '@store/ephemeral_store';
import intitialState from '@store/initial_state';
import Store from '@store/store';
import Preferences from 'mattermost-redux/constants/preferences';
jest.unmock('@actions/navigation');
jest.mock('@store/ephemeral_store', () => ({
import EphemeralStore from 'app/store/ephemeral_store';
import * as NavigationActions from 'app/actions/navigation';
jest.unmock('app/actions/navigation');
jest.mock('app/store/ephemeral_store', () => ({
getNavigationTopComponentId: jest.fn(),
clearNavigationComponents: jest.fn(),
}));
const mockStore = configureMockStore([thunk]);
const store = mockStore(intitialState);
Store.redux = store;
describe('@actions/navigation', () => {
describe('app/actions/navigation', () => {
const topComponentId = 'top-component-id';
const name = 'name';
const title = 'title';
@@ -44,12 +37,11 @@ describe('@actions/navigation', () => {
stack: {
children: [{
component: {
id: 'Channel',
name: 'Channel',
passProps,
options: {
layout: {
componentBackgroundColor: theme.centerChannelBg,
backgroundColor: 'transparent',
},
statusBar: {
visible: true,
@@ -58,12 +50,15 @@ describe('@actions/navigation', () => {
visible: false,
height: 0,
backButton: {
visible: false,
color: theme.sidebarHeaderTextColor,
title: '',
},
background: {
color: theme.sidebarHeaderBg,
},
title: {
color: theme.sidebarHeaderTextColor,
},
},
},
},
@@ -85,16 +80,11 @@ describe('@actions/navigation', () => {
stack: {
children: [{
component: {
id: 'SelectServer',
name: 'SelectServer',
passProps: {
allowOtherServers,
},
options: {
layout: {
backgroundColor: theme.centerChannelBg,
componentBackgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
},
@@ -125,7 +115,7 @@ describe('@actions/navigation', () => {
const defaultOptions = {
layout: {
componentBackgroundColor: theme.centerChannelBg,
backgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
@@ -151,7 +141,6 @@ describe('@actions/navigation', () => {
stack: {
children: [{
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -170,12 +159,7 @@ describe('@actions/navigation', () => {
const defaultOptions = {
layout: {
componentBackgroundColor: theme.centerChannelBg,
},
popGesture: true,
sideMenu: {
left: {enabled: false},
right: {enabled: false},
backgroundColor: theme.centerChannelBg,
},
topBar: {
animate: true,
@@ -196,7 +180,6 @@ describe('@actions/navigation', () => {
const expectedLayout = {
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -229,9 +212,8 @@ describe('@actions/navigation', () => {
const showModal = jest.spyOn(Navigation, 'showModal');
const defaultOptions = {
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
layout: {
componentBackgroundColor: theme.centerChannelBg,
backgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
@@ -259,7 +241,6 @@ describe('@actions/navigation', () => {
stack: {
children: [{
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -281,7 +262,6 @@ describe('@actions/navigation', () => {
modalPresentationStyle: 'overCurrentContext',
layout: {
backgroundColor: 'transparent',
componentBackgroundColor: 'transparent',
},
topBar: {
visible: false,
@@ -289,7 +269,6 @@ describe('@actions/navigation', () => {
},
animations: {
showModal: {
waitForRender: true,
enabled: animationsEnabled,
alpha: {
from: 0,
@@ -308,9 +287,8 @@ describe('@actions/navigation', () => {
},
};
const showModalOptions = {
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
layout: {
componentBackgroundColor: theme.centerChannelBg,
backgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
@@ -339,7 +317,6 @@ describe('@actions/navigation', () => {
stack: {
children: [{
component: {
id: name,
name,
passProps,
options: merge(showModalOptions, defaultOptions),
@@ -366,9 +343,8 @@ describe('@actions/navigation', () => {
},
};
const defaultOptions = {
modalPresentationStyle: Platform.select({ios: 'fullScreen', android: 'none'}),
layout: {
componentBackgroundColor: theme.centerChannelBg,
backgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
@@ -396,7 +372,6 @@ describe('@actions/navigation', () => {
stack: {
children: [{
component: {
id: showSearchModalName,
name: showSearchModalName,
passProps: showSearchModalPassProps,
options: merge(defaultOptions, showSearchModalOptions),
@@ -423,6 +398,27 @@ describe('@actions/navigation', () => {
expect(dismissAllModals).toHaveBeenCalledWith(options);
});
test('peek should call Navigation.push', async () => {
const push = jest.spyOn(Navigation, 'push');
const defaultOptions = {
preview: {
commit: false,
},
};
const expectedLayout = {
component: {
name,
passProps,
options: merge(defaultOptions, options),
},
};
await NavigationActions.peek(name, passProps, options);
expect(push).toHaveBeenCalledWith(topComponentId, expectedLayout);
});
test('mergeNavigationOptions should call Navigation.mergeOptions', () => {
const mergeOptions = jest.spyOn(Navigation, 'mergeOptions');
@@ -451,10 +447,6 @@ describe('@actions/navigation', () => {
const showOverlay = jest.spyOn(Navigation, 'showOverlay');
const defaultOptions = {
layout: {
backgroundColor: 'transparent',
componentBackgroundColor: 'transparent',
},
overlay: {
interceptTouchOutside: false,
},

View File

@@ -5,39 +5,64 @@ 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,
markChannelAsRead,
markChannelAsViewed,
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';
selectChannel,
getChannelStats,
} from 'mattermost-redux/actions/channels';
import {
getPosts,
getPostsBefore,
getPostsSince,
getPostThread,
} from 'mattermost-redux/actions/posts';
import {getFilesForPost} from 'mattermost-redux/actions/files';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getTeamMembersByIds, selectTeam} from 'mattermost-redux/actions/teams';
import {getProfilesInChannel} from 'mattermost-redux/actions/users';
import {General, Preferences} from 'mattermost-redux/constants';
import {getPostIdsInChannel} from 'mattermost-redux/selectors/entities/posts';
import {
getChannel,
getCurrentChannelId,
getMyChannelMember,
getRedirectChannelNameForTeam,
getChannelsNameMapInTeam,
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 {getChannelReachable} from 'app/selectors/channel';
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 telemetry from 'app/telemetry';
const MAX_RETRIES = 3;
import {
getChannelByName,
getDirectChannelName,
getUserIdFromChannelName,
isDirectChannel,
isGroupChannel,
getChannelByName as getChannelByNameSelector,
} from 'mattermost-redux/utils/channel_utils';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {getLastCreateAt} from 'mattermost-redux/utils/post_utils';
import {getPreferencesByCategory} from 'mattermost-redux/utils/preference_utils';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from 'app/constants/post_textbox';
import {isDirectChannelVisible, isGroupChannelVisible} from 'app/utils/channels';
const MAX_POST_TRIES = 3;
export function loadChannelsIfNecessary(teamId) {
return async (dispatch) => {
await dispatch(fetchMyChannelsAndMembers(teamId));
};
}
export function loadChannelsByTeamName(teamName, errorHandler) {
return async (dispatch, getState) => {
@@ -55,59 +80,187 @@ export function loadChannelsByTeamName(teamName, errorHandler) {
};
}
export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
return async (dispatch, getState) => {
const state = getState();
const {currentUserId, profilesInChannel} = state.entities.users;
const {channels, myMembers} = state.entities.channels;
const {myPreferences} = state.entities.preferences;
const {membersInTeam} = state.entities.teams;
const dmPrefs = getPreferencesByCategory(myPreferences, Preferences.CATEGORY_DIRECT_CHANNEL_SHOW);
const gmPrefs = getPreferencesByCategory(myPreferences, Preferences.CATEGORY_GROUP_CHANNEL_SHOW);
const members = [];
const loadProfilesForChannels = [];
const prefs = [];
function buildPref(name) {
return {
user_id: currentUserId,
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
name,
value: 'true',
};
}
// Find DM's and GM's that need to be shown
const directChannels = Object.values(channels).filter((c) => (isDirectChannel(c) || isGroupChannel(c)));
directChannels.forEach((channel) => {
const member = myMembers[channel.id];
if (isDirectChannel(channel) && !isDirectChannelVisible(currentUserId, myPreferences, channel) && member && member.mention_count > 0) {
const teammateId = getUserIdFromChannelName(currentUserId, channel.name);
let pref = dmPrefs.get(teammateId);
if (pref) {
pref = {...pref, value: 'true'};
} else {
pref = buildPref(teammateId);
}
dmPrefs.set(teammateId, pref);
prefs.push(pref);
} else if (isGroupChannel(channel) && !isGroupChannelVisible(myPreferences, channel) && member && (member.mention_count > 0 || member.msg_count < channel.total_msg_count)) {
const id = channel.id;
let pref = gmPrefs.get(id);
if (pref) {
pref = {...pref, value: 'true'};
} else {
pref = buildPref(id);
}
gmPrefs.set(id, pref);
prefs.push(pref);
}
});
if (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 +337,7 @@ export function selectPenultimateChannel(teamId) {
lastChannel.delete_at === 0 &&
(lastChannel.team_id === teamId || isDMVisible || isGMVisible)
) {
dispatch(setChannelLoading(true));
dispatch(handleSelectChannel(lastChannelId));
return;
}
@@ -197,7 +351,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;
@@ -215,38 +370,39 @@ export function selectDefaultChannel(teamId) {
};
}
export function handleSelectChannel(channelId) {
export function handleSelectChannel(channelId, fromPushNotification = false) {
return async (dispatch, getState) => {
const dt = Date.now();
const state = getState();
const {channels, currentChannelId, myMembers} = state.entities.channels;
const {currentTeamId} = state.entities.teams;
const channel = channels[channelId];
const member = myMembers[channelId];
const channel = getChannel(state, channelId);
const currentTeamId = getCurrentTeamId(state);
const currentChannelId = getCurrentChannelId(state);
const sameChannel = channelId === currentChannelId;
const member = getMyChannelMember(state, channelId);
if (channel) {
dispatch(setLoadMorePostsVisible(true));
// If the app is open from push notification, we already fetched the posts.
if (!fromPushNotification) {
dispatch(loadPostsIfNecessaryWithRetry(channelId));
let previousChannelId = null;
if (currentChannelId !== channelId) {
previousChannelId = currentChannelId;
}
const actions = markAsViewedAndReadBatch(state, channelId, previousChannelId);
actions.push({
type: ChannelTypes.SELECT_CHANNEL,
data: channelId,
extra: {
channel,
member,
teamId: channel.team_id || currentTeamId,
},
});
dispatch(batchActions(actions, 'BATCH_SWITCH_CHANNEL'));
console.log('channel switch to', channel?.display_name, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
}
let previousChannelId;
if (!fromPushNotification && !sameChannel) {
previousChannelId = currentChannelId;
}
const actions = [
selectChannel(channelId),
getChannelStats(channelId),
setChannelDisplayName(channel.display_name),
setInitialPostVisibility(channelId),
setChannelLoading(false),
setLastChannelForTeam(currentTeamId, channelId),
selectChannelWithMember(channelId, channel, member),
];
dispatch(batchActions(actions));
dispatch(markChannelViewedAndRead(channelId, previousChannelId));
};
}
@@ -284,16 +440,12 @@ export function handleSelectChannelByName(channelName, teamName, errorHandler) {
}
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,20 @@ export function insertToDraft(value) {
}
export function markChannelViewedAndRead(channelId, previousChannelId, markOnServer = true) {
return (dispatch, getState) => {
const state = getState();
const actions = markAsViewedAndReadBatch(state, channelId, previousChannelId, markOnServer);
dispatch(batchActions(actions, 'BATCH_MARK_CHANNEL_VIEWED_AND_READ'));
return (dispatch) => {
dispatch(markChannelAsRead(channelId, previousChannelId, markOnServer));
dispatch(markChannelAsViewed(channelId, previousChannelId));
};
}
export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', markOnServer = true) {
const actions = [];
const {channels, myMembers} = state.entities.channels;
const channel = channels[channelId];
const member = myMembers[channelId];
const prevMember = myMembers[prevChannelId];
const prevChanManuallyUnread = isManuallyUnread(state, prevChannelId);
const prevChannel = (!prevChanManuallyUnread && prevChannelId) ? channels[prevChannelId] : null; // May be null since prevChannelId is optional
if (markOnServer) {
Client4.viewMyChannel(channelId, prevChanManuallyUnread ? '' : prevChannelId).catch(() => {
// do nothing just adding the handler to avoid the warning
});
}
if (member) {
actions.push({
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
data: {...member, last_viewed_at: Date.now()},
});
if (isManuallyUnread(state, channelId)) {
actions.push({
type: ChannelTypes.REMOVE_MANUALLY_UNREAD,
data: {channelId},
});
}
if (channel) {
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));
dispatch(markChannelAsRead(channelId));
dispatch(markChannelAsViewed(channelId));
};
}
@@ -466,16 +540,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 +608,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 +620,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 +645,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 +653,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,6 +681,24 @@ export function increasePostVisibility(channelId, postId) {
};
}
export function increasePostVisibilityByOne(channelId) {
return (dispatch) => {
dispatch({
type: ViewTypes.INCREASE_POST_VISIBILITY,
data: channelId,
amount: 1,
});
};
}
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,
@@ -604,90 +706,26 @@ function setLoadMorePostsVisible(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 setInitialPostVisibility(channelId) {
return {
type: ViewTypes.SET_INITIAL_POST_VISIBILITY,
data: channelId,
};
}
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'));
}
function setLastChannelForTeam(teamId, channelId) {
return {
type: ViewTypes.SET_LAST_CHANNEL_FOR_TEAM,
teamId,
channelId,
};
}
function selectChannelWithMember(channelId, channel, member) {
return {
type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER,
data: channelId,
channel,
member,
};
}

View File

@@ -4,25 +4,24 @@
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import initialState from 'app/initial_state';
import {ViewTypes} from 'app/constants';
import testHelper from 'test/test_helper';
import * as ChannelActions from '@actions/views/channel';
import {ViewTypes} from '@constants';
import {ChannelTypes} from '@mm-redux/action_types';
import postReducer from '@mm-redux/reducers/entities/posts';
import initialState from '@store/initial_state';
import * as ChannelActions from 'app/actions/views/channel';
const {
handleSelectChannel,
handleSelectChannelByName,
loadPostsIfNecessaryWithRetry,
} = ChannelActions;
import postReducer from 'mattermost-redux/reducers/entities/posts';
const MOCK_CHANNEL_MARK_AS_READ = 'MOCK_CHANNEL_MARK_AS_READ';
const MOCK_CHANNEL_MARK_AS_VIEWED = 'MOCK_CHANNEL_MARK_AS_VIEWED';
jest.mock('@mm-redux/actions/channels', () => {
const channelActions = jest.requireActual('../../mm-redux/actions/channels');
jest.mock('mattermost-redux/actions/channels', () => {
const channelActions = require.requireActual('mattermost-redux/actions/channels');
return {
...channelActions,
markChannelAsRead: jest.fn().mockReturnValue({type: 'MOCK_CHANNEL_MARK_AS_READ'}),
@@ -30,8 +29,8 @@ jest.mock('@mm-redux/actions/channels', () => {
};
});
jest.mock('@mm-redux/selectors/entities/teams', () => {
const teamSelectors = jest.requireActual('../../mm-redux/selectors/entities/teams');
jest.mock('mattermost-redux/selectors/entities/teams', () => {
const teamSelectors = require.requireActual('mattermost-redux/selectors/entities/teams');
return {
...teamSelectors,
getTeamByName: jest.fn(() => ({name: 'current-team-name'})),
@@ -49,7 +48,7 @@ describe('Actions.Views.Channel', () => {
const MOCK_RECEIVED_POSTS_IN_CHANNEL = 'RECEIVED_POSTS_IN_CHANNEL';
const MOCK_RECEIVED_POSTS_SINCE = 'MOCK_RECEIVED_POSTS_SINCE';
const actions = require('@mm-redux/actions/channels');
const actions = require('mattermost-redux/actions/channels');
actions.getChannelByNameAndTeamName = jest.fn((teamName) => {
if (teamName) {
return {
@@ -67,7 +66,7 @@ describe('Actions.Views.Channel', () => {
type: MOCK_SELECT_CHANNEL_TYPE,
data: 'selected-channel-id',
});
const postActions = require('./post');
const postActions = require('mattermost-redux/actions/posts');
postActions.getPostsSince = jest.fn(() => {
return {
type: MOCK_RECEIVED_POSTS_SINCE,
@@ -97,7 +96,7 @@ describe('Actions.Views.Channel', () => {
};
});
const postUtils = require('@mm-redux/utils/post_utils');
const postUtils = require('mattermost-redux/utils/post_utils');
postUtils.getLastCreateAt = jest.fn((array) => {
return array[0].create_at;
});
@@ -117,29 +116,21 @@ describe('Actions.Views.Channel', () => {
},
channels: {
currentChannelId,
manuallyUnread: {},
channels: {
'channel-id': {id: 'channel-id', display_name: 'Test Channel'},
'channel-id-2': {id: 'channel-id-2', display_name: 'Test Channel'},
},
myMembers: {
'channel-id': {channel_id: 'channel-id', user_id: currentUserId, mention_count: 0, msg_count: 0},
'channel-id-2': {channel_id: 'channel-id-2', user_id: currentUserId, mention_count: 0, msg_count: 0},
},
},
teams: {
currentTeamId,
teams: {
[currentTeamId]: {
id: currentTeamId,
name: currentTeamName,
currentTeamId,
currentTeams: {
[currentTeamId]: {
name: currentTeamName,
},
},
},
},
},
};
const channelSelectors = require('@mm-redux/selectors/entities/channels');
const channelSelectors = require('mattermost-redux/selectors/entities/channels');
channelSelectors.getChannel = jest.fn((state, channelId) => ({data: channelId}));
channelSelectors.getCurrentChannelId = jest.fn(() => currentChannelId);
channelSelectors.getMyChannelMember = jest.fn(() => ({data: {member: {}}}));
@@ -156,13 +147,14 @@ 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';
failStoreObj.entities.teams.teams.currentTeamId = 'not-in-current-teams';
store = mockStore(failStoreObj);
await store.dispatch(handleSelectChannelByName(currentChannelName, null));
@@ -176,7 +168,6 @@ describe('Actions.Views.Channel', () => {
});
test('handleSelectChannelByName failure from no permission to channel', async () => {
store = mockStore({...storeObj});
actions.getChannelByNameAndTeamName = jest.fn(() => {
return {
type: 'MOCK_ERROR',
@@ -212,9 +203,9 @@ describe('Actions.Views.Channel', () => {
expect(postActions.getPosts).toBeCalled();
const storeActions = store.getActions();
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCH_LOAD_POSTS_IN_CHANNEL');
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCHING_REDUCER.BATCH');
const receivedPosts = storeActions.find(({type}) => type === MOCK_RECEIVED_POSTS);
const receivedPostsAtAction = storeBatchActions[0].payload.some((action) => action.type === ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME);
const receivedPostsAtAction = storeBatchActions[0].payload.some((action) => action.type === 'RECEIVED_POSTS_FOR_CHANNEL_AT_TIME');
nextPostState = postReducer(store.getState().entities.posts, receivedPosts);
nextPostState = postReducer(nextPostState, {
@@ -286,45 +277,35 @@ describe('Actions.Views.Channel', () => {
});
const handleSelectChannelCases = [
[currentChannelId],
[`${currentChannelId}-2`],
[`not-${currentChannelId}`],
[`not-${currentChannelId}-2`],
[currentChannelId, true],
[currentChannelId, false],
[`not-${currentChannelId}`, true],
[`not-${currentChannelId}`, false],
];
test.each(handleSelectChannelCases)('handleSelectChannel dispatches selectChannelWithMember', async (channelId) => {
const testObj = {...storeObj};
testObj.entities.teams.currentTeamId = currentTeamId;
store = mockStore(testObj);
test.each(handleSelectChannelCases)('handleSelectChannel dispatches selectChannelWithMember', async (channelId, fromPushNotification) => {
store = mockStore({...storeObj});
await store.dispatch(handleSelectChannel(channelId));
await store.dispatch(handleSelectChannel(channelId, fromPushNotification));
const storeActions = store.getActions();
const storeBatchActions = storeActions.find(({type}) => type === 'BATCH_SWITCH_CHANNEL');
const selectChannelWithMember = storeBatchActions?.payload.find(({type}) => type === ChannelTypes.SELECT_CHANNEL);
const storeBatchActions = storeActions.find(({type}) => type === 'BATCHING_REDUCER.BATCH');
const selectChannelWithMember = storeBatchActions.payload.find(({type}) => type === ViewTypes.SELECT_CHANNEL_WITH_MEMBER);
const viewedAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_VIEWED);
const readAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_READ);
const expectedSelectChannelWithMember = {
type: ChannelTypes.SELECT_CHANNEL,
type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER,
data: channelId,
extra: {
channel: {
id: channelId,
display_name: 'Test Channel',
},
member: {
channel_id: channelId,
user_id: currentUserId,
mention_count: 0,
msg_count: 0,
},
teamId: currentTeamId,
channel: {
data: channelId,
},
member: {
data: {
member: {},
},
},
};
if (channelId.includes('not')) {
expect(selectChannelWithMember).toBe(undefined);
} else {
expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember);
}
expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember);
expect(viewedAction).not.toBe(null);
expect(readAction).not.toBe(null);
});

View File

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

View File

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

View File

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

@@ -3,21 +3,40 @@
import moment from 'moment-timezone';
import {getDataRetentionPolicy} from '@mm-redux/actions/general';
import {GeneralTypes} from '@mm-redux/action_types';
import {getSessions} from '@mm-redux/actions/users';
import {autoUpdateTimezone} from '@mm-redux/actions/timezone';
import {Client4} from '@mm-redux/client';
import {getConfig, getLicense} from '@mm-redux/selectors/entities/general';
import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {getDataRetentionPolicy} from 'mattermost-redux/actions/general';
import {GeneralTypes} from 'mattermost-redux/action_types';
import {getSessions} from 'mattermost-redux/actions/users';
import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone';
import {Client4} from 'mattermost-redux/client';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {isTimezoneEnabled} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {ViewTypes} from 'app/constants';
import {setAppCredentials} from 'app/init/credentials';
import PushNotifications from 'app/push_notifications';
import {getDeviceTimezone} from 'app/utils/timezone';
import {getDeviceTimezoneAsync} from 'app/utils/timezone';
import {setCSRFFromCookie} from 'app/utils/security';
import {loadConfigAndLicense} from 'app/actions/views/root';
export function handleLoginIdChanged(loginId) {
return async (dispatch, getState) => {
dispatch({
type: ViewTypes.LOGIN_ID_CHANGED,
loginId,
}, getState);
};
}
export function handlePasswordChanged(password) {
return async (dispatch, getState) => {
dispatch({
type: ViewTypes.PASSWORD_CHANGED,
password,
}, getState);
};
}
export function handleSuccessfulLogin() {
return async (dispatch, getState) => {
await dispatch(loadConfigAndLicense());
@@ -35,7 +54,7 @@ export function handleSuccessfulLogin() {
const enableTimezone = isTimezoneEnabled(state);
if (enableTimezone) {
const timezone = getDeviceTimezone();
const timezone = await getDeviceTimezoneAsync();
dispatch(autoUpdateTimezone(timezone));
}
@@ -107,6 +126,8 @@ export function scheduleExpiredNotification(intl) {
}
export default {
handleLoginIdChanged,
handlePasswordChanged,
handleSuccessfulLogin,
scheduleExpiredNotification,
};

View File

@@ -4,14 +4,35 @@
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {Client4} from '@mm-redux/client';
import * as GeneralActions from 'mattermost-redux/actions/general';
import {handleSuccessfulLogin} from 'app/actions/views/login';
import {ViewTypes} from 'app/constants';
import {
handleLoginIdChanged,
handlePasswordChanged,
handleSuccessfulLogin,
} from 'app/actions/views/login';
jest.mock('app/init/credentials', () => ({
setAppCredentials: () => jest.fn(),
}));
jest.mock('react-native-cookies', () => ({
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
openURL: jest.fn(),
canOpenURL: jest.fn(),
getInitialURL: jest.fn(),
get: () => Promise.resolve(({
res: {
MMCSRF: {
value: 'the cookie',
},
},
})),
}));
const mockStore = configureStore([thunk]);
describe('Actions.Views.Login', () => {
@@ -30,9 +51,31 @@ describe('Actions.Views.Login', () => {
});
});
test('handleLoginIdChanged', () => {
const loginId = 'email@example.com';
const action = {
type: ViewTypes.LOGIN_ID_CHANGED,
loginId,
};
store.dispatch(handleLoginIdChanged(loginId));
expect(store.getActions()).toEqual([action]);
});
test('handlePasswordChanged', () => {
const password = 'password';
const action = {
type: ViewTypes.PASSWORD_CHANGED,
password,
};
store.dispatch(handlePasswordChanged(password));
expect(store.getActions()).toEqual([action]);
});
test('handleSuccessfulLogin gets config and license ', async () => {
const getClientConfig = jest.spyOn(Client4, 'getClientConfigOld');
const getLicenseConfig = jest.spyOn(Client4, 'getClientLicenseOld');
const getClientConfig = jest.spyOn(GeneralActions, 'getClientConfig');
const getLicenseConfig = jest.spyOn(GeneralActions, 'getLicenseConfig');
await store.dispatch(handleSuccessfulLogin());
expect(getClientConfig).toHaveBeenCalled();

View File

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

View File

@@ -1,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,36 +26,24 @@ export function startDataCleanup() {
export function loadConfigAndLicense() {
return async (dispatch, getState) => {
const {currentUserId} = getState().entities.users;
const [configData, licenseData] = await Promise.all([
getClientConfig()(dispatch, getState),
getLicenseConfig()(dispatch, getState),
]);
try {
const [config, license] = await Promise.all([
Client4.getClientConfigOld(),
Client4.getClientLicenseOld(),
]);
const config = configData.data || {};
const license = licenseData.data || {};
const actions = [{
type: GeneralTypes.CLIENT_CONFIG_RECEIVED,
data: config,
}, {
type: GeneralTypes.CLIENT_LICENSE_RECEIVED,
data: license,
}];
if (currentUserId) {
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
license.IsLicensed === 'true' && license.DataRetention === 'true') {
dispatch(getDataRetentionPolicy());
} else {
actions.push({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
}
if (currentUserId) {
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
license.IsLicensed === 'true' && license.DataRetention === 'true') {
getDataRetentionPolicy()(dispatch, getState);
} else {
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
}
dispatch(batchActions(actions, 'BATCH_LOAD_CONFIG_AND_LICENSE'));
return {config, license};
} catch (error) {
return {error};
}
return {config, license};
};
}
@@ -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, true));
};
}
export function purgeOfflineStore() {
return (dispatch, getState) => {
const currentState = getState();
dispatch({
type: General.OFFLINE_STORE_PURGE,
data: getStateForReset(initialState, currentState),
});
EventEmitter.emit(NavigationTypes.RESTART_APP);
};
return {type: General.OFFLINE_STORE_PURGE};
}
// A non-optimistic version of the createPost action in app/mm-redux with the file handling
// A non-optimistic version of the createPost action in mattermost-redux with the file handling
// removed since it's not needed.
export function createPostForNotificationReply(post) {
return async (dispatch, getState) => {

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

@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Fade should render {opacity: 0} 1`] = `
<ForwardRef(AnimatedComponentWrapper)
<AnimatedComponent
pointerEvents="box-none"
style={
Object {
@@ -17,11 +17,11 @@ exports[`Fade should render {opacity: 0} 1`] = `
<Text>
text
</Text>
</ForwardRef(AnimatedComponentWrapper)>
</AnimatedComponent>
`;
exports[`Fade should render {opacity: 1} 1`] = `
<ForwardRef(AnimatedComponentWrapper)
<AnimatedComponent
pointerEvents="box-none"
style={
Object {
@@ -37,5 +37,5 @@ exports[`Fade should render {opacity: 1} 1`] = `
<Text>
text
</Text>
</ForwardRef(AnimatedComponentWrapper)>
</AnimatedComponent>
`;

View File

@@ -1,6 +1,6 @@
// 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 {
@@ -35,7 +35,7 @@ exports[`SendAction should change theme backgroundColor to 0.3 opacity 1`] = `
</View>
`;
exports[`SendAction should match snapshot 1`] = `
exports[`SendButton should match snapshot 1`] = `
<TouchableWithFeedbackIOS
onPress={[MockFunction]}
style={
@@ -67,7 +67,7 @@ exports[`SendAction should match snapshot 1`] = `
</TouchableWithFeedbackIOS>
`;
exports[`SendAction should render theme backgroundColor 1`] = `
exports[`SendButton should render theme backgroundColor 1`] = `
<TouchableWithFeedbackIOS
onPress={[MockFunction]}
style={

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,7 +18,8 @@ exports[`AnnouncementBanner should match snapshot 1`] = `
]
}
>
<Component
<TouchableOpacity
activeOpacity={0.2}
onPress={[Function]}
style={
Array [
@@ -57,8 +58,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

@@ -70,7 +70,6 @@ export default class AnnouncementBanner extends PureComponent {
Animated.timing(this.state.bannerHeight, {
toValue: value,
duration: 350,
useNativeDriver: false,
}).start();
};

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

View File

@@ -3,8 +3,8 @@
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';

View File

@@ -6,7 +6,7 @@ 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';
@@ -33,15 +33,18 @@ 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,
});
}
}
@@ -56,13 +59,12 @@ export default class AtMention extends React.PureComponent {
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,13 +111,6 @@ 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 {user} = this.state;

View File

@@ -3,9 +3,9 @@
import {connect} from 'react-redux';
import {getUsersByUsername, getCurrentUserMentionKeys} from '@mm-redux/selectors/entities/users';
import {getUsersByUsername, getCurrentUserMentionKeys} 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 AtMention from './at_mention';

View File

@@ -8,7 +8,6 @@ import {
NativeModules,
Platform,
StyleSheet,
StatusBar,
} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import DeviceInfo from 'react-native-device-info';
@@ -19,7 +18,7 @@ import DocumentPicker from 'react-native-document-picker';
import ImagePicker from 'react-native-image-picker';
import Permissions from 'react-native-permissions';
import {lookupMimeType} from '@mm-redux/utils/file_utils';
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import emmProvider from 'app/init/emm_provider';
@@ -179,7 +178,6 @@ export default class AttachmentButton extends PureComponent {
if (hasCameraPermission) {
ImagePicker.launchCamera(options, (response) => {
StatusBar.setHidden(false);
emmProvider.inBackgroundSince = null;
if (response.error || response.didCancel) {
return;
@@ -215,7 +213,6 @@ export default class AttachmentButton extends PureComponent {
if (hasPhotoPermission) {
ImagePicker.launchImageLibrary(options, (response) => {
StatusBar.setHidden(false);
emmProvider.inBackgroundSince = null;
if (response.error || response.didCancel) {
return;
@@ -530,4 +527,4 @@ const style = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
});
});

View File

@@ -5,9 +5,9 @@ import React from 'react';
import {shallow} from 'enzyme';
import Permissions from 'react-native-permissions';
import {Alert, StatusBar} from 'react-native';
import {Alert} from 'react-native';
import Preferences from '@mm-redux/constants/preferences';
import Preferences from 'mattermost-redux/constants/preferences';
import {VALID_MIME_TYPES} from 'app/screens/edit_profile/edit_profile';
@@ -15,8 +15,7 @@ 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})),
launchCamera: jest.fn(),
}));
describe('AttachmentButton', () => {
@@ -105,32 +104,4 @@ describe('AttachmentButton', () => {
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,
@@ -37,7 +37,6 @@ export default class AtMention extends PureComponent {
value: PropTypes.string,
isLandscape: PropTypes.bool.isRequired,
nestedScrollEnabled: PropTypes.bool,
useChannelMentions: PropTypes.bool.isRequired,
};
static defaultProps = {
@@ -101,7 +100,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',

View File

@@ -4,10 +4,9 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {autocompleteUsers} from '@mm-redux/actions/users';
import {getCurrentChannelId, getDefaultChannel} from '@mm-redux/selectors/entities/channels';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {autocompleteUsers} from 'mattermost-redux/actions/users';
import {getCurrentChannelId, getDefaultChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {isLandscape} from 'app/selectors/device';
import {
@@ -16,10 +15,7 @@ import {
filterMembersInCurrentTeam,
getMatchTermForAtMention,
} from 'app/selectors/autocomplete';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles';
import {Permissions} from '@mm-redux/constants';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import AtMention from './at_mention';
@@ -27,18 +23,6 @@ function mapStateToProps(state, ownProps) {
const {cursorPosition, isSearch} = ownProps;
const currentChannelId = getCurrentChannelId(state);
let useChannelMentions = true;
if (isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) {
useChannelMentions = haveIChannelPermission(
state,
{
channel: currentChannelId,
permission: Permissions.USE_CHANNEL_MENTIONS,
default: true,
},
);
}
const value = ownProps.value.substring(0, cursorPosition);
const matchTerm = getMatchTermForAtMention(value, isSearch);
@@ -63,7 +47,6 @@ function mapStateToProps(state, ownProps) {
requestStatus: state.requests.users.autocompleteUsers.status,
theme: getTheme(state),
isLandscape: isLandscape(state),
useChannelMentions,
};
}

View File

@@ -19,7 +19,6 @@ export default class AtMentionItem extends PureComponent {
static propTypes = {
firstName: PropTypes.string,
lastName: PropTypes.string,
nickname: PropTypes.string,
onPress: PropTypes.func.isRequired,
userId: PropTypes.string.isRequired,
username: PropTypes.string,
@@ -44,7 +43,6 @@ export default class AtMentionItem extends PureComponent {
const {
firstName,
lastName,
nickname,
userId,
username,
theme,
@@ -56,7 +54,6 @@ export default class AtMentionItem extends PureComponent {
const style = getStyleFromTheme(theme);
const hasFullName = firstName.length > 0 && lastName.length > 0;
const hasNickname = nickname.length > 0;
return (
<TouchableWithFeedback
@@ -83,18 +80,13 @@ export default class AtMentionItem extends PureComponent {
theme={theme}
/>
{hasFullName && <Text style={style.rowUsername}>{' - '}</Text>}
<Text
style={style.rowFullname}
numberOfLines={1}
>
{hasFullName && `${firstName} ${lastName}`}
{hasNickname && ` (${nickname}) `}
{isCurrentUser &&
{hasFullName && <Text style={style.rowFullname}>{`${firstName} ${lastName}`}</Text>}
{isCurrentUser &&
<FormattedText
style={style.rowFullname}
id='suggestion.mention.you'
defaultMessage='(you)'
/>}
</Text>
</TouchableWithFeedback>
);
}
@@ -121,7 +113,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
rowFullname: {
color: theme.centerChannelColor,
opacity: 0.6,
flex: 1,
},
};
});

View File

@@ -3,9 +3,9 @@
import {connect} from 'react-redux';
import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users';
import {getCurrentUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import AtMentionItem from './at_mention_item';
@@ -18,7 +18,6 @@ function mapStateToProps(state, ownProps) {
return {
firstName: user.first_name,
lastName: user.last_name,
nickname: user.nickname,
username: user.username,
isBot: Boolean(user.is_bot),
isGuest: isGuest(user),

View File

@@ -9,7 +9,7 @@ import {
View,
} from 'react-native';
import EventEmitter from '@mm-redux/utils/event_emitter';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {DeviceTypes} from 'app/constants';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';

View File

@@ -3,7 +3,7 @@
import {connect} from 'react-redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import AutocompleteDivider from './autocomplete_divider';

View File

@@ -5,9 +5,9 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Platform, SectionList} from 'react-native';
import {RequestStatus} from '@mm-redux/constants';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {debounce} from '@mm-redux/actions/helpers';
import {RequestStatus} from 'mattermost-redux/constants';
import {isMinimumServerVersion} from 'mattermost-redux/utils/helpers';
import {debounce} from 'mattermost-redux/actions/helpers';
import {CHANNEL_MENTION_REGEX, CHANNEL_MENTION_SEARCH_REGEX} from 'app/constants/autocomplete';
import AutocompleteSectionHeader from 'app/components/autocomplete/autocomplete_section_header';
@@ -22,7 +22,7 @@ export default class ChannelMention extends PureComponent {
autocompleteChannelsForSearch: PropTypes.func.isRequired,
}).isRequired,
currentTeamId: PropTypes.string.isRequired,
cursorPosition: PropTypes.number,
cursorPosition: PropTypes.number.isRequired,
isSearch: PropTypes.bool,
matchTerm: PropTypes.string,
maxListHeight: PropTypes.number,

View File

@@ -4,9 +4,9 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {searchChannels, autocompleteChannelsForSearch} from '@mm-redux/actions/channels';
import {getMyChannelMemberships} from '@mm-redux/selectors/entities/channels';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {searchChannels, autocompleteChannelsForSearch} from 'mattermost-redux/actions/channels';
import {getMyChannelMemberships} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {isLandscape} from 'app/selectors/device';
import {
@@ -17,7 +17,7 @@ import {
filterDirectAndGroupMessages,
getMatchTermForChannelMention,
} from 'app/selectors/autocomplete';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import ChannelMention from './channel_mention';

View File

@@ -7,7 +7,7 @@ import {
Text,
} from 'react-native';
import {General} from '@mm-redux/constants';
import {General} from 'mattermost-redux/constants';
import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divider';
import {BotTag, GuestTag} from 'app/components/tag';
import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing';

View File

@@ -3,10 +3,10 @@
import {connect} from 'react-redux';
import {General} from '@mm-redux/constants';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getUser} from '@mm-redux/selectors/entities/users';
import {General} from 'mattermost-redux/constants';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {getChannelNameForSearchAutocomplete} from 'app/selectors/channel';
import {isLandscape} from 'app/selectors/device';

View File

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
import {CalendarList, LocaleConfig} from 'react-native-calendars';
import {intlShape} from 'react-intl';
import {memoizeResult} from '@mm-redux/utils/helpers';
import {memoizeResult} from 'mattermost-redux/utils/helpers';
import {DATE_MENTION_SEARCH_REGEX, ALL_SEARCH_FLAGS_REGEX} from 'app/constants/autocomplete';
import {changeOpacity} from 'app/utils/theme';
@@ -45,15 +45,21 @@ export default class DateSuggestion extends PureComponent {
this.setCalendarLocale();
}
componentDidUpdate(prevProps) {
const {locale, matchTerm} = this.props;
componentWillReceiveProps(nextProps) {
const {matchTerm} = nextProps;
if ((matchTerm !== prevProps.matchTerm && matchTerm === null) || this.state.mentionComplete) {
this.resetComponent();
if ((matchTerm !== this.props.matchTerm && matchTerm === null) || this.state.mentionComplete) {
// if the term changes but is null or the mention has been completed we render this component as null
this.setState({
mentionComplete: false,
sections: [],
});
this.props.onResultCountChange(0);
}
if (locale !== prevProps.locale) {
this.setCalendarLocale();
if (this.props.locale !== nextProps.locale) {
this.setCalendarLocale(nextProps);
}
}
@@ -74,20 +80,10 @@ export default class DateSuggestion extends PureComponent {
this.setState({mentionComplete: true});
};
resetComponent() {
this.setState({
mentionComplete: false,
sections: [],
});
this.props.onResultCountChange(0);
}
setCalendarLocale = () => {
const {locale} = this.props;
setCalendarLocale = (props = this.props) => {
const {formatMessage} = this.context.intl;
LocaleConfig.locales[locale] = {
LocaleConfig.locales[props.locale] = {
monthNames: formatMessage({
id: 'mobile.calendar.monthNames',
defaultMessage: 'January,February,March,April,May,June,July,August,September,October,November,December',
@@ -106,7 +102,7 @@ export default class DateSuggestion extends PureComponent {
}).split(','),
};
LocaleConfig.defaultLocale = locale;
LocaleConfig.defaultLocale = props.locale;
};
render() {
@@ -126,7 +122,6 @@ export default class DateSuggestion extends PureComponent {
<CalendarList
style={styles.calList}
current={currentDate}
maxDate={currentDate}
pastScrollRange={24}
futureScrollRange={0}
scrollingEnabled={true}

View File

@@ -3,7 +3,7 @@
import {connect} from 'react-redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {makeGetMatchTermForDateMention} from 'app/selectors/autocomplete';
import {getCurrentLocale} from 'app/selectors/i18n';

View File

@@ -10,12 +10,12 @@ import {
View,
} from 'react-native';
import AutocompleteDivider from '@components/autocomplete/autocomplete_divider';
import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {BuiltInEmojis} from '@utils/emojis';
import {getEmojiByName, compareEmojis} from '@utils/emoji_utils';
import {makeStyleSheetFromTheme} from '@utils/theme';
import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divider';
import Emoji from 'app/components/emoji';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {BuiltInEmojis} from 'app/utils/emojis';
import {getEmojiByName, compareEmojis} from 'app/utils/emoji_utils';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
const EMOJI_REGEX = /(^|\s|^\+|^-)(:([^:\s]*))$/i;
const EMOJI_REGEX_WITHOUT_PREFIX = /\B(:([^:\s]*))$/i;
@@ -27,10 +27,9 @@ export default class EmojiSuggestion extends PureComponent {
autocompleteCustomEmojis: PropTypes.func.isRequired,
}).isRequired,
cursorPosition: PropTypes.number,
customEmojisEnabled: PropTypes.bool,
emojis: PropTypes.array.isRequired,
fuse: PropTypes.object.isRequired,
isSearch: PropTypes.bool,
fuse: PropTypes.object.isRequired,
maxListHeight: PropTypes.number,
theme: PropTypes.object.isRequired,
onChangeText: PropTypes.func.isRequired,
@@ -56,29 +55,62 @@ export default class EmojiSuggestion extends PureComponent {
this.matchTerm = '';
}
componentDidUpdate() {
if (this.props.isSearch) {
componentWillReceiveProps(nextProps) {
if (nextProps.isSearch) {
return;
}
const {cursorPosition, value} = this.props;
const match = value.substring(0, cursorPosition).match(EMOJI_REGEX);
const regex = EMOJI_REGEX;
const match = nextProps.value.substring(0, nextProps.cursorPosition).match(regex);
if (!match || this.state.emojiComplete) {
this.resetAutocomplete();
this.setState({
active: false,
emojiComplete: false,
});
this.props.onResultCountChange(0);
return;
}
const oldMatchTerm = this.matchTerm;
this.matchTerm = match[3] || '';
if (this.matchTerm !== oldMatchTerm || match[2] === ':') {
if (this.props.customEmojisEnabled) {
this.props.actions.autocompleteCustomEmojis(this.matchTerm);
}
this.searchEmojis(this.matchTerm);
if (this.matchTerm !== oldMatchTerm && this.matchTerm.length) {
this.props.actions.autocompleteCustomEmojis(this.matchTerm);
return;
}
if (this.matchTerm.length) {
this.handleFuzzySearch(this.matchTerm, nextProps);
} else {
this.setEmojiData(nextProps.emojis);
}
}
handleFuzzySearch = async (matchTerm, props) => {
const {emojis, fuse} = props;
const results = await fuse.search(matchTerm.toLowerCase());
const data = results.map((index) => emojis[index]);
this.setEmojiData(data, matchTerm);
};
setEmojiData = (data, matchTerm = null) => {
let sorter = compareEmojis;
if (matchTerm) {
sorter = (a, b) => compareEmojis(a, b, matchTerm);
}
this.setState({
active: data.length > 0,
dataSource: data.sort(sorter),
});
this.props.onResultCountChange(data.length);
};
completeSuggestion = (emoji) => {
const {actions, cursorPosition, onChangeText, value, rootId} = this.props;
const emojiPart = value.substring(0, cursorPosition);
@@ -97,11 +129,7 @@ export default class EmojiSuggestion extends PureComponent {
const emojiData = getEmojiByName(emoji);
if (emojiData?.filename && !BuiltInEmojis.includes(emojiData.filename)) {
const codeArray = emojiData.filename.split('-');
const code = codeArray.reduce((acc, c) => {
return acc + String.fromCodePoint(parseInt(c, 16));
}, '');
completedDraft = emojiPart.replace(EMOJI_REGEX_WITHOUT_PREFIX, `${code} `);
completedDraft = emojiPart.replace(EMOJI_REGEX_WITHOUT_PREFIX, String.fromCodePoint(parseInt(emojiData.filename, 16)));
} else {
completedDraft = emojiPart.replace(EMOJI_REGEX_WITHOUT_PREFIX, `${prefix}${emoji}: `);
}
@@ -127,8 +155,6 @@ export default class EmojiSuggestion extends PureComponent {
});
};
getItemLayout = ({index}) => ({length: 40, offset: 40 * index, index})
keyExtractor = (item) => item;
renderItem = ({item}) => {
@@ -152,48 +178,7 @@ export default class EmojiSuggestion extends PureComponent {
);
};
resetAutocomplete = () => {
this.setState({
active: false,
emojiComplete: false,
});
this.props.onResultCountChange(0);
}
searchEmojis = (searchTerm) => {
const {emojis, fuse} = this.props;
let sorter = compareEmojis;
if (searchTerm.trim().length) {
const searchTermLowerCase = searchTerm.toLowerCase();
sorter = (a, b) => compareEmojis(a, b, searchTermLowerCase);
clearTimeout(this.searchTermTimeout);
this.searchTermTimeout = setTimeout(() => {
const fuzz = fuse.search(searchTerm);
const results = fuzz.reduce((values, r) => {
const v = r.matches[0]?.value;
if (v) {
values.push(v);
}
return values;
}, []);
const data = results.sort(sorter);
this.setState({
active: data.length > 0,
dataSource: data,
});
}, 100);
} else {
this.setState({
active: emojis.length > 0,
dataSource: emojis.sort(sorter),
});
}
};
getItemLayout = ({index}) => ({length: 40, offset: 40 * index, index})
render() {
const {maxListHeight, theme, nestedScrollEnabled} = this.props;

View File

@@ -1,86 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import Fuse from 'fuse.js';
import Preferences from '@mm-redux/constants/preferences';
import {selectEmojisByName} from '@selectors/emojis';
import initialState from '@store/initial_state';
import {shallowWithIntl} from 'test/intl-test-helper';
import EmojiSuggestion from './emoji_suggestion';
jest.useFakeTimers();
describe('components/autocomplete/emoji_suggestion', () => {
const state = {
...initialState,
views: {
recentEmojis: [],
},
};
const emojis = selectEmojisByName(state);
const options = {
shouldSort: false,
threshold: 0.3,
location: 0,
distance: 10,
includeMatches: true,
findAllMatches: true,
};
const fuse = new Fuse(emojis, options);
const baseProps = {
actions: {
addReactionToLatestPost: jest.fn(),
autocompleteCustomEmojis: jest.fn(),
},
cursorPosition: 0,
customEmojisEnabled: false,
emojis,
fuse,
isSearch: false,
theme: Preferences.THEMES.default,
onChangeText: jest.fn(),
onResultCountChange: jest.fn(),
rootId: '',
value: '',
nestedScrollEnabled: false,
};
test('should match snapshot', () => {
const wrapper = shallowWithIntl(<EmojiSuggestion {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
wrapper.setProps({cursorPosition: 1, value: ':1'});
expect(wrapper.getElement()).toMatchSnapshot();
});
test('searchEmojis should return the right values on fuse', () => {
const output1 = ['100', '1234', '1st_place_medal', '+1', '-1', 'u7121'];
const output2 = ['+1'];
const output3 = ['-1'];
const wrapper = shallowWithIntl(<EmojiSuggestion {...baseProps}/>);
wrapper.instance().searchEmojis('');
expect(wrapper.state('dataSource')).toEqual(baseProps.emojis);
wrapper.instance().searchEmojis('1');
jest.runAllTimers();
setTimeout(() => {
expect(wrapper.state('dataSource')).toEqual(output1);
}, 100);
wrapper.instance().searchEmojis('+');
jest.runAllTimers();
setTimeout(() => {
expect(wrapper.state('dataSource')).toEqual(output2);
}, 100);
wrapper.instance().searchEmojis('-');
jest.runAllTimers();
setTimeout(() => {
expect(wrapper.state('dataSource')).toEqual(output3);
}, 100);
});
});

View File

@@ -2,35 +2,48 @@
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {createSelector} from 'reselect';
import {bindActionCreators} from 'redux';
import Fuse from 'fuse.js';
import {addReactionToLatestPost} from '@actions/views/emoji';
import {autocompleteCustomEmojis} from '@mm-redux/actions/emojis';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {selectEmojisByName} from '@selectors/emojis';
import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
import {autocompleteCustomEmojis} from 'mattermost-redux/actions/emojis';
import {addReactionToLatestPost} from 'app/actions/views/emoji';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {EmojiIndicesByAlias} from 'app/utils/emojis';
import EmojiSuggestion from './emoji_suggestion';
import Fuse from 'fuse.js';
const getEmojisByName = createSelector(
getCustomEmojisByName,
(customEmojis) => {
const emoticons = new Set();
for (const [key] of [...EmojiIndicesByAlias.entries(), ...customEmojis.entries()]) {
emoticons.add(key);
}
return Array.from(emoticons);
},
);
function mapStateToProps(state) {
const emojis = selectEmojisByName(state);
const options = {
shouldSort: false,
shouldSort: true,
threshold: 0.3,
location: 0,
distance: 10,
includeMatches: true,
findAllMatches: true,
distance: 100,
minMatchCharLength: 2,
maxPatternLength: 32,
};
const emojis = getEmojisByName(state);
const list = emojis.length ? emojis : [];
const fuse = new Fuse(list, options);
return {
fuse,
emojis,
customEmojisEnabled: getConfig(state).EnableCustomEmoji === 'true',
theme: getTheme(state),
};
}

View File

@@ -3,7 +3,7 @@
import {connect} from 'react-redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getDimensions} from 'app/selectors/device';

View File

@@ -5,10 +5,10 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {createSelector} from 'reselect';
import {getAutocompleteCommands} from '@mm-redux/actions/integrations';
import {getAutocompleteCommandsList} from '@mm-redux/selectors/entities/integrations';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {getAutocompleteCommands} from 'mattermost-redux/actions/integrations';
import {getAutocompleteCommandsList} from 'mattermost-redux/selectors/entities/integrations';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {isLandscape} from 'app/selectors/device';
import SlashSuggestion from './slash_suggestion';

View File

@@ -51,7 +51,7 @@ export default class SlashSuggestionItem extends PureComponent {
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
row: {
paddingVertical: 8,
height: 55,
justifyContent: 'center',
paddingHorizontal: 8,
backgroundColor: theme.centerChannelBg,

View File

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
import {intlShape} from 'react-intl';
import Icon from 'react-native-vector-icons/FontAwesome';
import {displayUsername} from '@mm-redux/utils/user_utils';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
import FormattedText from 'app/components/formatted_text';
import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing';

View File

@@ -4,7 +4,7 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
import {getTeammateNameDisplaySetting, getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {setAutocompleteSelector} from 'app/actions/views/post';

View File

@@ -8,7 +8,7 @@ import {
View,
} from 'react-native';
import {General} from '@mm-redux/constants';
import {General} from 'mattermost-redux/constants';
import Icon from 'app/components/vector_icon';

View File

@@ -9,8 +9,8 @@ import {
} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
import {displayUsername} from '@mm-redux/utils/user_utils';
import {General} from '@mm-redux/constants';
import {getFullName} from 'mattermost-redux/utils/user_utils';
import {General} from 'mattermost-redux/constants';
import {goToScreen} from 'app/actions/navigation';
import ProfilePicture from 'app/components/profile_picture';
@@ -30,7 +30,6 @@ class ChannelIntro extends PureComponent {
intl: intlShape.isRequired,
theme: PropTypes.object.isRequired,
isLandscape: PropTypes.bool.isRequired,
teammateNameDisplay: PropTypes.string.isRequired,
};
static defaultProps = {
@@ -49,12 +48,11 @@ class ChannelIntro extends PureComponent {
};
getDisplayName = (member) => {
const {teammateNameDisplay} = this.props;
if (!member) {
return null;
}
const displayName = displayUsername(member, teammateNameDisplay);
const displayName = getFullName(member);
if (!displayName) {
return member.username;
@@ -353,7 +351,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
marginBottom: 12,
},
container: {
marginTop: 10,
marginTop: 60,
marginHorizontal: 12,
marginBottom: 12,
overflow: 'hidden',

View File

@@ -4,11 +4,11 @@
import {connect} from 'react-redux';
import {createSelector} from 'reselect';
import {General} from '@mm-redux/constants';
import {makeGetChannel} from '@mm-redux/selectors/entities/channels';
import {getCurrentUserId, getUser, makeGetProfilesInChannel} from '@mm-redux/selectors/entities/users';
import {General} from 'mattermost-redux/constants';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId, getUser, makeGetProfilesInChannel} from 'mattermost-redux/selectors/entities/users';
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {isLandscape} from 'app/selectors/device';
import {getChannelMembersForDm} from 'app/selectors/channel';
@@ -49,7 +49,6 @@ function makeMapStateToProps() {
currentChannelMembers,
theme: getTheme(state),
isLandscape: isLandscape(state),
teammateNameDisplay: getTeammateNameDisplaySetting(state),
};
};
}

View File

@@ -5,14 +5,14 @@ import React from 'react';
import {shallow} from 'enzyme';
import {Text} from 'react-native';
import {alertErrorWithFallback} from '@utils/general';
import {alertErrorWithFallback} from 'app/utils/general';
import ChannelLink from './channel_link';
jest.mock('react-intl');
jest.mock('@utils/general', () => {
const general = jest.requireActual('../../utils/general');
jest.mock('app/utils/general', () => {
const general = require.requireActual('app/utils/general');
return {
...general,
alertErrorWithFallback: jest.fn(),

View File

@@ -5,10 +5,10 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {createSelector} from 'reselect';
import {joinChannel} from '@mm-redux/actions/channels';
import {getChannelsNameMapInCurrentTeam} from '@mm-redux/selectors/entities/channels';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {joinChannel} from 'mattermost-redux/actions/channels';
import {getChannelsNameMapInCurrentTeam} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {handleSelectChannel} from 'app/actions/views/channel';

View File

@@ -17,12 +17,13 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<View
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -30,39 +31,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -70,39 +62,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -110,39 +93,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -150,39 +124,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -190,39 +155,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -230,39 +186,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -270,39 +217,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -310,39 +248,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -350,39 +279,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -390,39 +310,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -430,39 +341,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -470,39 +372,30 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<View
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
<AnimatedComponent
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "row",
"marginVertical": 10,
"paddingLeft": 12,
"paddingRight": 20,
@@ -510,32 +403,22 @@ exports[`ChannelLoader should match snapshot 1`] = `
Object {
"backgroundColor": "#ffffff",
},
Object {
"opacity": 0.6,
},
]
}
>
<UNDEFINED
Animation={[Function]}
Left={[Function]}
styles={
Object {
"left": Object {
"color": "rgba(61,60,64,0.15)",
},
}
}
>
<UNDEFINED
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</View>
<ImageContent
animate="fade"
color="rgba(61,60,64,0.15)"
firstLineWidth="80%"
hasRadius={true}
lineNumber={3}
lineSpacing={5}
size={32}
textSize={14}
/>
</AnimatedComponent>
</View>
`;

View File

@@ -5,32 +5,30 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Animated,
Easing,
View,
Dimensions,
} from 'react-native';
import * as RNPlaceholder from 'rn-placeholder';
import {ImageContent} from 'rn-placeholder';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import CustomPropTypes from 'app/constants/custom_prop_types';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing';
const {View: AnimatedView} = Animated;
function calculateMaxRows(height) {
return Math.round(height / 100);
}
function Media(color) {
return (
<RNPlaceholder.PlaceholderMedia
color={changeOpacity(color, 0.15)}
isRound={true}
size={32}
style={{marginRight: 10}}
/>
);
}
export default class ChannelLoader extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
handleSelectChannel: PropTypes.func.isRequired,
setChannelLoading: PropTypes.func.isRequired,
}).isRequired,
backgroundColor: PropTypes.string,
channelIsLoading: PropTypes.bool.isRequired,
style: CustomPropTypes.Style,
@@ -66,36 +64,89 @@ export default class ChannelLoader extends PureComponent {
return Object.keys(state) ? state : null;
}
componentDidMount() {
EventEmitter.on('switch_channel', this.handleChannelSwitch);
}
componentWillUnmount() {
EventEmitter.off('switch_channel', this.handleChannelSwitch);
}
startLoadingAnimation = () => {
Animated.loop(
Animated.sequence([
Animated.timing(this.state.barsOpacity, {
toValue: 1,
duration: 750,
easing: Easing.quad,
useNativeDriver: true,
}),
Animated.timing(this.state.barsOpacity, {
toValue: 0.6,
duration: 750,
easing: Easing.quad,
useNativeDriver: true,
}),
]),
).start();
};
stopLoadingAnimation = () => {
Animated.timing(
this.state.barsOpacity,
).stop();
}
componentDidUpdate(prevProps) {
if (prevProps.channelIsLoading === false && this.props.channelIsLoading === true) {
this.startLoadingAnimation();
} else if (prevProps.channelIsLoading === true && this.props.channelIsLoading === false) {
this.stopLoadingAnimation();
}
if (this.state.switch) {
const {
handleSelectChannel,
setChannelLoading,
} = this.props.actions;
const {channel} = this.state;
setTimeout(() => {
handleSelectChannel(channel.id);
setChannelLoading(false);
}, 250);
}
}
buildSections({key, style, bg, color}) {
return (
<View
<AnimatedView
key={key}
style={[style.section, {backgroundColor: bg}]}
style={[style.section, {backgroundColor: bg}, {opacity: this.state.barsOpacity}]}
>
<RNPlaceholder.Placeholder
Animation={(props) => (
<RNPlaceholder.Fade
{...props}
style={{backgroundColor: changeOpacity(bg, 0.9)}}
/>
)}
Left={Media.bind(undefined, color)}
styles={{left: {color: changeOpacity(color, 0.15)}}}
>
<RNPlaceholder.PlaceholderLine color={changeOpacity(color, 0.15)}/>
<RNPlaceholder.PlaceholderLine
color={changeOpacity(color, 0.15)}
width={80}
/>
<RNPlaceholder.PlaceholderLine
color={changeOpacity(color, 0.15)}
width={60}
/>
</RNPlaceholder.Placeholder>
</View>
<ImageContent
size={32}
animate='fade'
lineNumber={3}
lineSpacing={5}
firstLineWidth='80%'
hasRadius={true}
textSize={14}
color={changeOpacity(color, 0.15)}
/>
</AnimatedView>
);
}
handleChannelSwitch = (channel, currentChannelId) => {
if (channel.id === currentChannelId) {
this.props.actions.setChannelLoading(false);
} else {
this.setState({switch: true, channel});
}
};
handleLayout = (e) => {
const {height} = e.nativeEvent.layout;
const maxRows = calculateMaxRows(height);
@@ -141,6 +192,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
},
section: {
backgroundColor: theme.centerChannelBg,
flexDirection: 'row',
flex: 1,
paddingLeft: 12,
paddingRight: 20,

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