Compare commits

...

84 Commits

Author SHA1 Message Date
Elias Nahum
79c47e3ce9 Fix bad merge 2020-03-11 19:09:09 -03:00
Elias Nahum
47682ca4ed Bump app build number to 276 (#4028) 2020-03-11 18:27:11 -03:00
Mattermost Build
4f36dbc4d0 MM-23176 Fix crash due to scrollToIndex out of range (#4027)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-11 18:23:02 -03:00
Elias Nahum
1c0e0e888c translations PR 20200309 (#4023) 2020-03-10 12:03:20 -03:00
Elias Nahum
5300bd9553 Bump app build number to 275 (#4022) 2020-03-09 17:45:54 -03:00
Elias Nahum
ebb20591c0 MM-22975 Fix archive handle when server does not allow view archive channel (#4019)
* MM-22975 Fix archive handle when server does not allow vieweing an archive channel

* Update mm-redux ref
2020-03-09 17:36:08 -03:00
Mattermost Build
22a51acb50 Bump app build number to 274 (#4018)
Co-authored-by: Miguel Alatzar <this.migbot@gmail.com>
2020-03-06 11:48:33 -07:00
Miguel Alatzar
732b301f0d Fix app hanging when switching to a team after opening push notification (#4015)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-05 09:56:02 -07:00
Elias Nahum
62adde0ad0 MM-23014 Keep Android cache for vector icons (#4014)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-05 13:33:03 -03:00
Elias Nahum
345b5662ec Bump app build number to 273 (#4007)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-04 15:54:05 -03:00
Mattermost Build
356a092843 Flush store on logout (#4003)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-04 13:24:07 -03:00
Mattermost Build
fae40137fd MM-22963 Fix Android post GIF from keyboard (#4000)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-04 09:12:11 -03:00
Mattermost Build
4d4f50dac7 Automated cherry pick of #3994 (#3999)
* Ensure modals opened from the Settings Sidebar have a close icon

* Feedback during review

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-04 09:11:39 -03:00
Mattermost Build
6f53f9be16 Fix typing on input box after returning from an archived channel (#3995)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-03 20:27:06 -03:00
Mattermost Build
c009047003 Automated cherry pick of #3988 (#3991)
* MM-22790 Fix Load more joinable/archived channels in more channels screen

* Add unit tests

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-03 13:40:11 -03:00
Elias Nahum
446ddf91d4 translations PR 20200302 (#3990) 2020-03-03 09:39:55 -07:00
Elias Nahum
687968bf39 MM-22752 Preserve other teams channel membership (#3987) (#3989) 2020-03-02 16:13:07 -03:00
Mattermost Build
4bd719789f Bump app build number to 272 (#3985)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-01 10:12:25 -03:00
Mattermost Build
e059d7ea3e Automated cherry pick of #3924 (#3983)
* Ensure isFirstRow prop is set on first row

* Determine maxPreviewColumns on mount

* Fix expand button offset on Android

* Fix moreBelow gradient

* Show right gradient when columns are sliced

* Fix moreRight conditions

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-03-01 09:32:04 -03:00
Mattermost Build
6a2a99b0d6 Automated cherry pick of #3978 (#3982)
* MM-22686 decrease request timeout to 5s

* MM-22752 Fix switching teams with and without connction

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-03-01 09:29:15 -03:00
Miguel Alatzar
b4d3e6c9b1 [MM-20950] Don't reset the navigation root when clearing data (#3929) (#3980)
* No need to resetToChannel on clearing data

* Don't use componentWillReceiveProps

* Dismiss all modals on iOS too

* Use centerChannelBg when channel as root

* Fix resetToChannel test

* Fix removal of loadChannels call when rebasing with master

* Add new line

* Address PR review comments
2020-02-27 21:09:09 -07:00
Mattermost Build
b49e0a1c62 Add set_online query param (#3979)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-27 20:46:38 -07:00
Mattermost Build
a346a989c3 Automated cherry pick of #3921 (#3977)
* MM-21276 & MM-22419 Fix PostList insets and Channel Intro

* Update patches/react-native-keyboard-tracking-view+5.6.1.patch

Co-Authored-By: Miguel Alatzar <migbot@users.noreply.github.com>

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-27 17:10:38 -03:00
Mattermost Build
1d9135704c Automated cherry pick of #3971 (#3976)
* MM-22379 & MM-22598 Boost perf by using FastImage cache in favor of ImageCacheManager

* Code review

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-27 16:45:20 -03:00
Mattermost Build
cf6cfac5e2 Automated cherry pick of #3970 (#3974)
* update android downloader

* update en.json

Co-authored-by: Hossein Ahmadian-Yazdi <hahmadia@users.noreply.github.com>
2020-02-27 13:34:24 -05:00
Mattermost Build
a398687955 Fetch archived channels (#3973)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-27 10:15:04 -03:00
Mattermost Build
73008d1a5b Automated cherry pick of #3967 (#3969)
* Fix in-app notification crashing when receiving multiple

* Dismiss the overlay when tap without waiting for interaction manager

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-26 13:00:37 -03:00
Mattermost Build
a3df718db7 Update fastlane (#3966)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-26 12:54:08 -03:00
Elias Nahum
f90aba6632 Update redux (#3947) (#3963)
To include this new changes https://github.com/mattermost/mattermost-redux/pull/1051

Co-authored-by: Mario de Frutos Dieguez <mario@defrutos.org>
2020-02-26 11:44:13 -03:00
Elias Nahum
71450be466 translations PR 20200225 (#3961) 2020-02-25 18:16:45 -05:00
Mattermost Build
bacbc5a734 - Change "Copy Permalink" text in post options menu (#3957) 2020-02-25 16:52:12 +02:00
Mattermost Build
d28e747688 Update react-native-cookies (#3956)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-23 10:31:51 -05:00
Mattermost Build
c969bdbbef Bump app build number to 271 (#3955)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-21 17:46:33 -05:00
Mattermost Build
338d627e75 Automated cherry pick of #3952 (#3954)
* Fix rn-fetch-blob cast exception

* Use toString

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-21 17:46:12 -05:00
Mattermost Build
942b088583 MM-22629 Center-align tall & thin image attachments (#3945)
Bug reported in community: https://community.mattermost.com/core/pl/yy39ajg1ajfm386dms6tdyjrtw
2020-02-18 15:51:20 -03:00
Elias Nahum
7ef8eb294c update mm-redux 2020-02-18 09:58:47 -05:00
Mattermost Build
0ea99445ca Bump app build number to 270 (#3943)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-18 11:44:49 -03:00
Miguel Alatzar
3383c93df8 Performance improvements (#3911) (#3941)
* Improve sidebar performance on first load

* Initial work for switch channel

* Revert android changes

* Split Sidebar per Platform

* Fix waitForHydration executing the callback more than once

* Fix custom emoji not showing on Android

* Finalize Channel Switch

* Enable Android Ram Bundles

* Select the right team for lastChannelForTeam

* Channel loading post indicator

* Fix main sidebar base intl provider

* Update mm-redux

* No need to request configAndLicense on launch

* Load channel member roles

* Rename closeChannelDrawer to closeMainSidebar

* do not throw errors when console is called while running tests

* constant for LOADING_POSTS_HEIGHT

* Remove show more if a long post is edited and no longer long

* Update mm-redux#batch-actions branch

* Code review

* Clear notifications if channel was switched

* Import Platform

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

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-17 21:23:39 -07:00
Miguel Alatzar
a575f95797 Bump app build number to 269 (#3934) (#3937) 2020-02-17 13:45:56 -07:00
Mattermost Build
62d873e45d Bump app version number to 1.29.0 (#3936)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-17 13:38:50 -07:00
Mattermost Build
cbae026f8e MM-20184 Remove ios drawer hack and Fix right drawer partially shown on landscape (#3933)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-17 12:32:11 -07:00
Mattermost Build
99bbc79953 Use JobIntentService over IntentService (#3932)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-17 12:28:08 -07:00
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
331 changed files with 15263 additions and 7947 deletions

View File

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

View File

@@ -75,7 +75,8 @@ import com.android.build.OutputFile
project.ext.react = [
entryFile: "index.js",
bundleConfig: "metro.config.js",
enableHermes: true,
bundleCommand: "ram-bundle",
enableHermes: false,
]
apply from: "../../node_modules/react-native/react.gradle"
@@ -105,18 +106,8 @@ def enableSeparateBuildPerCPUArchitecture = false
*/
def enableProguardInReleaseBuilds = false
/**
* The preferred build flavor of JavaScriptCore.
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'org.webkit:android-jsc-intl:r241213'
// Add v8-android - prebuilt libv8android.so into APK
def jscFlavor = 'org.chromium:v8-android:+'
/**
* Whether to enable the Hermes VM.
@@ -140,8 +131,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
missingDimensionStrategy "RNN.reactNativeVersion", "reactNative60"
versionCode 259
versionName "1.27.0"
versionCode 276
versionName "1.29.0"
multiDexEnabled = true
ndk {
abiFilters 'armeabi-v7a','arm64-v8a','x86','x86_64'
@@ -200,6 +191,11 @@ android {
sourceCompatibility 1.8
targetCompatibility 1.8
}
packagingOptions {
// Make sure libjsc.so does not packed in APK
exclude "**/libjsc.so"
}
}
repositories {
@@ -232,6 +228,7 @@ dependencies {
def hermesPath = "../../node_modules/hermes-engine/android/";
debugImplementation files(hermesPath + "hermes-debug.aar")
releaseImplementation files(hermesPath + "hermes-release.aar")
unsignedImplementation files(hermesPath + "hermes-release.aar")
} else {
implementation jscFlavor
}
@@ -267,6 +264,7 @@ dependencies {
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

View File

@@ -29,7 +29,8 @@
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -39,7 +40,7 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mattermost-beta" />
<data android:scheme="mattermost" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />

View File

@@ -33,6 +33,7 @@ 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;
@@ -153,6 +154,8 @@ public class MainApplication extends NavigationApplication implements INotificat
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);
}
@@ -187,6 +190,7 @@ public class MainApplication extends NavigationApplication implements INotificat
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;
}
};
@@ -208,7 +212,7 @@ public class MainApplication extends NavigationApplication implements INotificat
instance = this;
// Delete any previous temp files created by the app
File tempFolder = new File(getApplicationContext().getCacheDir(), "mmShare");
File tempFolder = new File(getApplicationContext().getCacheDir(), ShareModule.CACHE_DIR_NAME);
RealPathUtil.deleteTempFiles(tempFolder);
Log.i("ReactNative", "Cleaning temp cache " + tempFolder.getAbsolutePath());

View File

@@ -63,7 +63,6 @@ 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);
}
}
@@ -88,12 +87,16 @@ 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(String.format("%s/api/v4/posts", serverUrl.replaceAll("/$", "")))
.post(body)
.build();
.header("Authorization", String.format("Bearer %s", token))
.header("Content-Type", "application/json")
.url(url)
.post(body)
.build();
client.newCall(request).enqueue(new okhttp3.Callback() {
@Override

View File

@@ -16,8 +16,12 @@ import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.uimanager.events.RCTEventEmitter;
import com.mattermost.share.RealPathUtil;
import com.mattermost.share.ShareModule;
import java.io.FileNotFoundException;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.regex.Matcher;
public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteListener {
@@ -83,6 +87,14 @@ public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteList
// Get fileName
String fileName = URLUtil.guessFileName(uri, null, mimeType);
if (uri.contains(ShareModule.CACHE_DIR_NAME)) {
uri = moveToImagesCache(uri, fileName);
}
if (uri == null) {
return;
}
// Get fileSize
long fileSize;
try {
@@ -119,4 +131,24 @@ public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteList
event
);
}
private String moveToImagesCache(String src, String fileName) {
ReactContext ctx = (ReactContext)mEditText.getContext();
String cacheFolder = ctx.getCacheDir().getAbsolutePath() + "/Images/";
String dest = cacheFolder + fileName;
File folder = new File(cacheFolder);
try {
if (!folder.exists()) {
folder.mkdirs();
}
Files.move(Paths.get(src), Paths.get(dest));
} catch (Exception err) {
return null;
}
return dest;
}
}

View File

@@ -113,7 +113,7 @@ public class RealPathUtil {
}
File cacheDir = new File(context.getCacheDir(), "mmShare");
File cacheDir = new File(context.getCacheDir(), ShareModule.CACHE_DIR_NAME);
if (!cacheDir.exists()) {
cacheDir.mkdirs();
}

View File

@@ -39,6 +39,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
private final OkHttpClient client = new OkHttpClient();
public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
private final MainApplication mApplication;
public static final String CACHE_DIR_NAME = "mmShare";
public ShareModule(MainApplication application, ReactApplicationContext reactContext) {
super(reactContext);
@@ -67,6 +68,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
@Override
public Map<String, Object> getConstants() {
HashMap<String, Object> constants = new HashMap<>(1);
constants.put("cacheDirName", CACHE_DIR_NAME);
constants.put("isOpened", mApplication.sharedExtensionIsOpened);
mApplication.sharedExtensionIsOpened = false;
return constants;
@@ -133,7 +135,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
Activity currentActivity = getCurrentActivity();
if (currentActivity != null) {
this.tempFolder = new File(currentActivity.getCacheDir(), "mmShare");
this.tempFolder = new File(currentActivity.getCacheDir(), CACHE_DIR_NAME);
Intent intent = currentActivity.getIntent();
action = intent.getAction();
type = intent.getType();

View File

@@ -44,11 +44,17 @@ allprojects {
jcenter()
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url "$rootDir/../node_modules/react-native/android"
// url "$rootDir/../node_modules/react-native/android"
// Replace AAR from original RN with AAR from react-native-v8
url("$rootDir/../node_modules/react-native-v8/dist")
}
maven {
// Local Maven repo containing AARs with JSC library built for Android
url "$rootDir/../node_modules/jsc-android/dist"
// url "$rootDir/../node_modules/jsc-android/dist"
// prebuilt libv8android.so
url("$rootDir/../node_modules/v8-android/dist")
}
maven {
url "https://jitpack.io"

View File

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

View File

@@ -1,9 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
import {Keyboard, Platform} from 'react-native';
import {Navigation} from 'react-native-navigation';
import merge from 'deepmerge';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
@@ -20,38 +19,60 @@ function getThemeFromState() {
export function resetToChannel(passProps = {}) {
const theme = getThemeFromState();
Navigation.setRoot({
root: {
stack: {
children: [{
component: {
name: 'Channel',
passProps,
options: {
layout: {
backgroundColor: 'transparent',
},
statusBar: {
visible: true,
},
topBar: {
visible: false,
height: 0,
backButton: {
color: theme.sidebarHeaderTextColor,
title: '',
},
background: {
color: theme.sidebarHeaderBg,
},
title: {
color: theme.sidebarHeaderTextColor,
},
},
const stack = {
children: [{
component: {
id: 'Channel',
name: 'Channel',
passProps,
options: {
layout: {
backgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
},
topBar: {
visible: false,
height: 0,
background: {
color: theme.sidebarHeaderBg,
},
backButton: {
visible: false,
},
},
}],
},
},
}],
};
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,
},
});
}
@@ -64,6 +85,7 @@ export function resetToSelectServer(allowOtherServers) {
stack: {
children: [{
component: {
id: 'SelectServer',
name: 'SelectServer',
passProps: {
allowOtherServers,
@@ -121,6 +143,7 @@ export function resetToTeams(name, title, passProps = {}, options = {}) {
stack: {
children: [{
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -138,6 +161,10 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
layout: {
backgroundColor: theme.centerChannelBg,
},
sideMenu: {
left: {enabled: false},
right: {enabled: false},
},
topBar: {
animate: true,
visible: true,
@@ -157,6 +184,7 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
Navigation.push(componentId, {
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -216,6 +244,7 @@ export function showModal(name, title, passProps = {}, options = {}) {
stack: {
children: [{
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -295,23 +324,6 @@ 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: {
@@ -350,3 +362,77 @@ 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;
}
const componentId = EphemeralStore.getNavigationTopComponentId();
Keyboard.dismiss();
Navigation.mergeOptions(componentId, {
sideMenu: {
left: {visible: false},
},
});
}
export function enableMainSideMenu(enabled, visible = true) {
if (Platform.OS === 'ios') {
return;
}
const componentId = EphemeralStore.getNavigationTopComponentId();
Navigation.mergeOptions(componentId, {
sideMenu: {
left: {enabled, visible},
},
});
}
export function openSettingsSideMenu() {
if (Platform.OS === 'ios') {
return;
}
const componentId = EphemeralStore.getNavigationTopComponentId();
Keyboard.dismiss();
Navigation.mergeOptions(componentId, {
sideMenu: {
right: {visible: true},
},
});
}
export function closeSettingsSideMenu() {
if (Platform.OS === 'ios') {
return;
}
const componentId = EphemeralStore.getNavigationTopComponentId();
Keyboard.dismiss();
Navigation.mergeOptions(componentId, {
sideMenu: {
right: {visible: false},
},
});
}

View File

@@ -37,11 +37,12 @@ describe('app/actions/navigation', () => {
stack: {
children: [{
component: {
id: 'Channel',
name: 'Channel',
passProps,
options: {
layout: {
backgroundColor: 'transparent',
backgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
@@ -50,15 +51,11 @@ describe('app/actions/navigation', () => {
visible: false,
height: 0,
backButton: {
color: theme.sidebarHeaderTextColor,
title: '',
visible: false,
},
background: {
color: theme.sidebarHeaderBg,
},
title: {
color: theme.sidebarHeaderTextColor,
},
},
},
},
@@ -80,6 +77,7 @@ describe('app/actions/navigation', () => {
stack: {
children: [{
component: {
id: 'SelectServer',
name: 'SelectServer',
passProps: {
allowOtherServers,
@@ -141,6 +139,7 @@ describe('app/actions/navigation', () => {
stack: {
children: [{
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -161,6 +160,10 @@ describe('app/actions/navigation', () => {
layout: {
backgroundColor: theme.centerChannelBg,
},
sideMenu: {
left: {enabled: false},
right: {enabled: false},
},
topBar: {
animate: true,
visible: true,
@@ -180,6 +183,7 @@ describe('app/actions/navigation', () => {
const expectedLayout = {
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -241,6 +245,7 @@ describe('app/actions/navigation', () => {
stack: {
children: [{
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
@@ -317,6 +322,7 @@ describe('app/actions/navigation', () => {
stack: {
children: [{
component: {
id: name,
name,
passProps,
options: merge(showModalOptions, defaultOptions),
@@ -372,6 +378,7 @@ describe('app/actions/navigation', () => {
stack: {
children: [{
component: {
id: showSearchModalName,
name: showSearchModalName,
passProps: showSearchModalPassProps,
options: merge(defaultOptions, showSearchModalOptions),
@@ -398,27 +405,6 @@ describe('app/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');

View File

@@ -5,15 +5,13 @@ import {batchActions} from 'redux-batched-actions';
import {ViewTypes} from 'app/constants';
import {UserTypes} from 'mattermost-redux/action_types';
import {UserTypes, ChannelTypes} from 'mattermost-redux/action_types';
import {
fetchMyChannelsAndMembers,
getChannelByNameAndTeamName,
markChannelAsRead,
markChannelAsViewed,
leaveChannel as serviceLeaveChannel,
selectChannel,
getChannelStats,
} from 'mattermost-redux/actions/channels';
import {
getPosts,
@@ -23,21 +21,22 @@ import {
} from 'mattermost-redux/actions/posts';
import {getFilesForPost} from 'mattermost-redux/actions/files';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
import {getTeamMembersByIds, selectTeam} from 'mattermost-redux/actions/teams';
import {getProfilesInChannel} from 'mattermost-redux/actions/users';
import {Client4} from 'mattermost-redux/client';
import {General, Preferences} from 'mattermost-redux/constants';
import {getPostIdsInChannel} from 'mattermost-redux/selectors/entities/posts';
import {
getChannel,
getCurrentChannelId,
getMyChannelMember,
getRedirectChannelNameForTeam,
getChannelsNameMapInTeam,
isManuallyUnread,
} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId, getTeamByName} from 'mattermost-redux/selectors/entities/teams';
import telemetry from 'app/telemetry';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId, getUserIdsInChannels, getUsers} from 'mattermost-redux/selectors/entities/users';
import {getTeamByName} from 'mattermost-redux/selectors/entities/teams';
import {
getChannelByName,
@@ -52,22 +51,25 @@ 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';
import {getChannelReachable} from 'app/selectors/channel';
import telemetry from 'app/telemetry';
import {isDirectChannelVisible, isGroupChannelVisible, isDirectMessageVisible, isGroupMessageVisible, isDirectChannelAutoClosed} from 'app/utils/channels';
import {buildPreference} from 'app/utils/preferences';
const MAX_POST_TRIES = 3;
import {forceLogoutIfNecessary} from './user';
export function loadChannelsIfNecessary(teamId) {
return async (dispatch) => {
await dispatch(fetchMyChannelsAndMembers(teamId));
};
}
const MAX_RETRIES = 3;
export function loadChannelsByTeamName(teamName) {
export function loadChannelsByTeamName(teamName, errorHandler) {
return async (dispatch, getState) => {
const state = getState();
const {currentTeamId} = state.entities.teams;
const team = getTeamByName(state, teamName);
if (!team && errorHandler) {
errorHandler();
}
if (team && team.id !== currentTeamId) {
await dispatch(fetchMyChannelsAndMembers(team.id));
}
@@ -184,22 +186,10 @@ export function loadPostsIfNecessaryWithRetry(channelId) {
const time = Date.now();
let loadMorePostsVisible = true;
let received;
let postAction;
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
received = await retryGetPostsAction(getPosts(channelId), dispatch, getState);
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,
},
});
}
postAction = getPosts(channelId);
} else {
const lastConnectAt = state.websocket?.lastConnectAt || 0;
const lastGetPosts = state.views.channel.lastGetPosts[channelId];
@@ -215,27 +205,22 @@ export function loadPostsIfNecessaryWithRetry(channelId) {
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,
},
});
}
postAction = getPostsSince(channelId, since);
}
const received = await retryGetPostsAction(postAction, dispatch, getState);
if (received) {
actions.push({
type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
channelId,
time,
});
if (received?.order) {
const count = received.order.length;
loadMorePostsVisible = count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
}
}
actions.push(setLoadMorePostsVisible(loadMorePostsVisible));
@@ -243,8 +228,8 @@ export function loadPostsIfNecessaryWithRetry(channelId) {
};
}
export async function retryGetPostsAction(action, dispatch, getState, maxTries = MAX_POST_TRIES) {
for (let i = 0; i < maxTries; i++) {
export async function retryGetPostsAction(action, dispatch, getState, maxTries = MAX_RETRIES) {
for (let i = 0; i <= maxTries; i++) {
const {data} = await dispatch(action); // eslint-disable-line no-await-in-loop
if (data) {
@@ -331,7 +316,6 @@ export function selectPenultimateChannel(teamId) {
lastChannel.delete_at === 0 &&
(lastChannel.team_id === teamId || isDMVisible || isGMVisible)
) {
dispatch(setChannelLoading(true));
dispatch(handleSelectChannel(lastChannelId));
return;
}
@@ -346,7 +330,6 @@ export function selectDefaultChannel(teamId) {
const channelsInTeam = getChannelsNameMapInTeam(state, teamId);
const channel = getChannelByNameSelector(channelsInTeam, getRedirectChannelNameForTeam(state, teamId));
let channelId;
if (channel) {
channelId = channel.id;
@@ -364,43 +347,36 @@ export function selectDefaultChannel(teamId) {
};
}
export function handleSelectChannel(channelId, fromPushNotification = false) {
export function handleSelectChannel(channelId) {
return async (dispatch, getState) => {
const dt = Date.now();
const state = getState();
const channel = getChannel(state, channelId);
const currentTeamId = getCurrentTeamId(state);
const currentChannelId = getCurrentChannelId(state);
const sameChannel = channelId === currentChannelId;
const member = getMyChannelMember(state, channelId);
const {channels, currentChannelId, myMembers} = state.entities.channels;
const {currentTeamId} = state.entities.teams;
const channel = channels[channelId];
const member = myMembers[channelId];
dispatch(setLoadMorePostsVisible(true));
dispatch(loadPostsIfNecessaryWithRetry(channelId));
// If the app is open from push notification, we already fetched the posts.
if (!fromPushNotification) {
dispatch(loadPostsIfNecessaryWithRetry(channelId));
if (channel && currentChannelId !== channelId) {
dispatch({
type: ChannelTypes.SELECT_CHANNEL,
data: channelId,
extra: {
channel,
member,
teamId: channel.team_id || currentTeamId,
},
});
dispatch(markChannelViewedAndRead(channelId, currentChannelId));
}
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));
console.log('channel switch to', channel?.display_name, (Date.now() - dt), 'ms'); //eslint-disable-line
};
}
export function handleSelectChannelByName(channelName, teamName) {
export function handleSelectChannelByName(channelName, teamName, errorHandler) {
return async (dispatch, getState) => {
const state = getState();
const {teams: currentTeams, currentTeamId} = state.entities.teams;
@@ -409,7 +385,13 @@ export function handleSelectChannelByName(channelName, teamName) {
const response = await dispatch(getChannelByNameAndTeamName(teamName || currentTeamName, channelName));
const {error, data: channel} = response;
const currentChannelId = getCurrentChannelId(state);
const reachable = getChannelReachable(state, channelName, teamName);
if (!reachable && errorHandler) {
errorHandler();
}
// Fallback to API response error, if any.
if (error) {
return {error};
}
@@ -596,8 +578,7 @@ export function setChannelDisplayName(displayName) {
export function increasePostVisibility(channelId, postId) {
return async (dispatch, getState) => {
const state = getState();
const {loadingPosts, postVisibility} = state.views.channel;
const currentPostVisibility = postVisibility[channelId] || 0;
const {loadingPosts} = state.views.channel;
if (loadingPosts[channelId]) {
return true;
@@ -608,20 +589,6 @@ export function increasePostVisibility(channelId, postId) {
return true;
}
// 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;
}
telemetry.reset();
telemetry.start(['posts:loading']);
@@ -646,18 +613,6 @@ export function increasePostVisibility(channelId, postId) {
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));
}
@@ -669,24 +624,6 @@ 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,
@@ -694,26 +631,203 @@ function setLoadMorePostsVisible(visible) {
};
}
function setInitialPostVisibility(channelId) {
return {
type: ViewTypes.SET_INITIAL_POST_VISIBILITY,
data: channelId,
export function loadChannelsForTeam(teamId) {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
if (currentUserId) {
const data = {sync: true, teamId};
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) {
const result = await dispatch(forceLogoutIfNecessary(err)); //eslint-disable-line no-await-in-loop
if (result || i === MAX_RETRIES) {
const hasChannelsLoaded = state.entities.channels.channelsInTeam[teamId]?.size > 0;
return {error: hasChannelsLoaded ? null : err};
}
}
}
if (data.channels) {
const roles = new Set();
const members = data.channelMembers;
for (const member of members) {
for (const role of member.roles.split(' ')) {
roles.add(role);
}
}
if (roles.size > 0) {
dispatch(loadRolesIfNeeded(roles));
}
// Fetch needed profiles from channel creators and direct channels
dispatch(loadSidebarDirectMessagesProfiles(data));
dispatch({
type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS,
data,
});
}
return {data};
}
return {error: 'Cannot fetch channels without a current user'};
};
}
function setLastChannelForTeam(teamId, channelId) {
return {
type: ViewTypes.SET_LAST_CHANNEL_FOR_TEAM,
teamId,
channelId,
export function loadSidebarDirectMessagesProfiles(data) {
return async (dispatch, getState) => {
const state = getState();
const {channels, channelMembers} = data;
const currentUserId = getCurrentUserId(state);
const usersInChannel = getUserIdsInChannels(state);
const directChannels = Object.values(channels).filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
const prefs = [];
const promises = []; //only fetch profiles that we don't have and the Direct channel should be visible
// Prepare preferences and start fetching profiles to batch them
directChannels.forEach((c) => {
const profilesInChannel = Array.from(usersInChannel[c.id] || []).filter((u) => u.id !== currentUserId);
switch (c.type) {
case General.DM_CHANNEL: {
const dm = fetchDirectMessageProfileIfNeeded(state, c, channelMembers, profilesInChannel, prefs);
if (dm) {
promises.push(dispatch(dm));
}
break;
}
case General.GM_CHANNEL: {
const gm = fetchGroupMessageProfilesIfNeeded(state, c, channelMembers, profilesInChannel, prefs);
if (gm) {
promises.push(dispatch(gm));
}
break;
}
}
});
// Save preferences if there are any changes
if (prefs.length) {
dispatch(savePreferences(currentUserId, prefs));
}
getProfilesFromPromises(dispatch, promises, directChannels);
return {data: true};
};
}
function selectChannelWithMember(channelId, channel, member) {
return {
type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER,
data: channelId,
channel,
member,
export function getUsersInChannel(channelId) {
return async (dispatch, getState) => {
try {
const state = getState();
const currentUserId = getCurrentUserId(state);
const profiles = await Client4.getProfilesInChannel(channelId);
// When fetching profiles in channels we exclude our own user
const users = profiles.filter((p) => p.id !== currentUserId);
const data = {
channelId,
users,
};
return {data};
} catch (error) {
return {error};
}
};
}
async function getProfilesFromPromises(dispatch, promiseArray, directChannels) {
// Get the profiles returned by the promises and retry those that failed
let promises = promiseArray;
for (let i = 0; i <= MAX_RETRIES; i++) {
if (!promises.length) {
return;
}
const result = await Promise.all(promises); //eslint-disable-line no-await-in-loop
const failed = [];
result.forEach((p, index) => {
if (p.error) {
failed.push(directChannels[index].id);
}
});
dispatch({
type: UserTypes.RECEIVED_BATCHED_PROFILES_IN_CHANNEL,
data: result,
});
if (failed.length) {
promises = failed.map((id) => dispatch(getUsersInChannel(id))); //eslint-disable-line no-loop-func
continue;
}
return;
}
}
function fetchDirectMessageProfileIfNeeded(state, channel, channelMembers, profilesInChannel, newPreferences) {
const currentUserId = getCurrentUserId(state);
const preferences = getMyPreferences(state);
const users = getUsers(state);
const config = getConfig(state);
const currentChannelId = getCurrentChannelId(state);
const otherUserId = getUserIdFromChannelName(currentUserId, channel.name);
const otherUser = users[otherUserId];
const dmVisible = isDirectMessageVisible(preferences, channel.id);
const dmAutoClosed = isDirectChannelAutoClosed(config, preferences, channel.id, channel.last_post_at, otherUser?.delete_at, currentChannelId); //eslint-disable-line camelcase
const dmIsUnread = channelMembers[channel.id]?.mention_count > 0; //eslint-disable-line camelcase
const dmFetchProfile = dmIsUnread || (dmVisible && !dmAutoClosed);
// when then DM is hidden but has new messages
if ((!dmVisible || dmAutoClosed) && dmIsUnread) {
newPreferences.push(buildPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, currentUserId, otherUserId));
newPreferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
}
if (dmFetchProfile && !profilesInChannel.includes(otherUserId) && otherUserId !== currentUserId) {
return getUsersInChannel(channel.id);
}
return null;
}
function fetchGroupMessageProfilesIfNeeded(state, channel, channelMembers, profilesInChannel, newPreferences) {
const currentUserId = getCurrentUserId(state);
const preferences = getMyPreferences(state);
const config = getConfig(state);
const gmVisible = isGroupMessageVisible(preferences, channel.id);
const gmAutoClosed = isDirectChannelAutoClosed(config, preferences, channel.id, channel.last_post_at);
const channelMember = channelMembers[channel.id];
const gmIsUnread = channelMember?.mention_count > 0 || channelMember?.msg_count < channel.total_msg_count; //eslint-disable-line camelcase
const gmFetchProfile = gmIsUnread || (gmVisible && !gmAutoClosed);
// when then GM is hidden but has new messages
if ((!gmVisible || gmAutoClosed) && gmIsUnread) {
newPreferences.push(buildPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, currentUserId, channel.id));
newPreferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
}
if (gmFetchProfile && !profilesInChannel.length) {
return getUsersInChannel(channel.id);
}
return null;
}

View File

@@ -5,7 +5,7 @@ import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import initialState from 'app/initial_state';
import {ViewTypes} from 'app/constants';
import {ChannelTypes} from 'mattermost-redux/action_types';
import testHelper from 'test/test_helper';
import * as ChannelActions from 'app/actions/views/channel';
@@ -116,14 +116,21 @@ describe('Actions.Views.Channel', () => {
},
channels: {
currentChannelId,
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,
currentTeams: {
[currentTeamId]: {
name: currentTeamName,
},
[currentTeamId]: {
id: currentTeamId,
name: currentTeamName,
},
},
},
@@ -135,6 +142,9 @@ describe('Actions.Views.Channel', () => {
channelSelectors.getCurrentChannelId = jest.fn(() => currentChannelId);
channelSelectors.getMyChannelMember = jest.fn(() => ({data: {member: {}}}));
const appChannelSelectors = require('app/selectors/channel');
appChannelSelectors.getChannelReachable = jest.fn(() => true);
test('handleSelectChannelByName success', async () => {
store = mockStore(storeObj);
@@ -144,14 +154,13 @@ describe('Actions.Views.Channel', () => {
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
expect(receivedChannel).toBe(true);
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCHING_REDUCER.BATCH');
const selectedChannel = storeBatchActions[0].payload.some((action) => action.type === MOCK_SELECT_CHANNEL_TYPE);
const selectedChannel = storeActions.some(({type}) => type === MOCK_RECEIVE_CHANNEL_TYPE);
expect(selectedChannel).toBe(true);
});
test('handleSelectChannelByName failure from null currentTeamName', async () => {
const failStoreObj = {...storeObj};
failStoreObj.entities.teams.teams.currentTeamId = 'not-in-current-teams';
failStoreObj.entities.teams.currentTeamId = 'not-in-current-teams';
store = mockStore(failStoreObj);
await store.dispatch(handleSelectChannelByName(currentChannelName, null));
@@ -165,6 +174,7 @@ describe('Actions.Views.Channel', () => {
});
test('handleSelectChannelByName failure from no permission to channel', async () => {
store = mockStore({...storeObj});
actions.getChannelByNameAndTeamName = jest.fn(() => {
return {
type: 'MOCK_ERROR',
@@ -181,6 +191,18 @@ describe('Actions.Views.Channel', () => {
expect(receivedChannel).toBe(false);
});
test('handleSelectChannelByName failure from unreachable channel', async () => {
appChannelSelectors.getChannelReachable = jest.fn(() => false);
store = mockStore(storeObj);
await store.dispatch(handleSelectChannelByName(currentChannelName, currentTeamName));
const storeActions = store.getActions();
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
expect(receivedChannel).toBe(false);
});
test('loadPostsIfNecessaryWithRetry for the first time', async () => {
store = mockStore(storeObj);
@@ -262,35 +284,44 @@ describe('Actions.Views.Channel', () => {
});
const handleSelectChannelCases = [
[currentChannelId, true],
[currentChannelId, false],
[`not-${currentChannelId}`, true],
[`not-${currentChannelId}`, false],
[currentChannelId],
[`${currentChannelId}-2`],
[`not-${currentChannelId}`],
[`not-${currentChannelId}-2`],
];
test.each(handleSelectChannelCases)('handleSelectChannel dispatches selectChannelWithMember', async (channelId, fromPushNotification) => {
store = mockStore({...storeObj});
test.each(handleSelectChannelCases)('handleSelectChannel dispatches selectChannelWithMember', async (channelId) => {
const testObj = {...storeObj};
testObj.entities.teams.currentTeamId = currentTeamId;
store = mockStore(testObj);
await store.dispatch(handleSelectChannel(channelId, fromPushNotification));
await store.dispatch(handleSelectChannel(channelId));
const storeActions = store.getActions();
const storeBatchActions = storeActions.find(({type}) => type === 'BATCHING_REDUCER.BATCH');
const selectChannelWithMember = storeBatchActions.payload.find(({type}) => type === ViewTypes.SELECT_CHANNEL_WITH_MEMBER);
const selectChannelWithMember = storeActions.find(({type}) => type === ChannelTypes.SELECT_CHANNEL);
const viewedAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_VIEWED);
const readAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_READ);
const expectedSelectChannelWithMember = {
type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER,
type: ChannelTypes.SELECT_CHANNEL,
data: channelId,
channel: {
data: channelId,
},
member: {
data: {
member: {},
extra: {
channel: {
id: channelId,
display_name: 'Test Channel',
},
member: {
channel_id: channelId,
user_id: currentUserId,
mention_count: 0,
msg_count: 0,
},
teamId: currentTeamId,
},
};
expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember);
if (channelId.includes('not') || channelId === currentChannelId) {
expect(selectChannelWithMember).toBe(undefined);
} else {
expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember);
}
expect(viewedAction).not.toBe(null);
expect(readAction).not.toBe(null);
});

View File

@@ -17,6 +17,7 @@ import {setAppCredentials} from 'app/init/credentials';
import PushNotifications from 'app/push_notifications';
import {getDeviceTimezoneAsync} from 'app/utils/timezone';
import {setCSRFFromCookie} from 'app/utils/security';
import {loadConfigAndLicense} from 'app/actions/views/root';
export function handleLoginIdChanged(loginId) {
return async (dispatch, getState) => {
@@ -38,6 +39,8 @@ export function handlePasswordChanged(password) {
export function handleSuccessfulLogin() {
return async (dispatch, getState) => {
await dispatch(loadConfigAndLicense());
const state = getState();
const config = getConfig(state);
const license = getLicense(state);

View File

@@ -4,11 +4,14 @@
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import * as GeneralActions from 'mattermost-redux/actions/general';
import {ViewTypes} from 'app/constants';
import {
handleLoginIdChanged,
handlePasswordChanged,
handleSuccessfulLogin,
} from 'app/actions/views/login';
jest.mock('app/init/credentials', () => ({
@@ -36,7 +39,16 @@ describe('Actions.Views.Login', () => {
let store;
beforeEach(() => {
store = mockStore({});
store = mockStore({
entities: {
users: {
currentUserId: 'current-user-id',
},
general: {
config: {},
},
},
});
});
test('handleLoginIdChanged', () => {
@@ -60,4 +72,13 @@ describe('Actions.Views.Login', () => {
store.dispatch(handlePasswordChanged(password));
expect(store.getActions()).toEqual([action]);
});
test('handleSuccessfulLogin gets config and license ', async () => {
const getClientConfig = jest.spyOn(GeneralActions, 'getClientConfig');
const getLicenseConfig = jest.spyOn(GeneralActions, 'getLicenseConfig');
await store.dispatch(handleSuccessfulLogin());
expect(getClientConfig).toHaveBeenCalled();
expect(getLicenseConfig).toHaveBeenCalled();
});
});

View File

@@ -1,18 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GeneralTypes} from 'mattermost-redux/action_types';
import {batchActions} from 'redux-batched-actions';
import {ChannelTypes, GeneralTypes, TeamTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import {General} from 'mattermost-redux/constants';
import {fetchMyChannelsAndMembers} from 'mattermost-redux/actions/channels';
import {getClientConfig, getDataRetentionPolicy, getLicenseConfig} from 'mattermost-redux/actions/general';
import {receivedNewPost} from 'mattermost-redux/actions/posts';
import {getMyTeams, getMyTeamMembers, selectTeam} from 'mattermost-redux/actions/teams';
import {getMyTeams, getMyTeamMembers} from 'mattermost-redux/actions/teams';
import {ViewTypes} from 'app/constants';
import EphemeralStore from 'app/store/ephemeral_store';
import {recordTime} from 'app/utils/segment';
import {handleSelectChannel} from 'app/actions/views/channel';
import {markChannelViewedAndRead} from './channel';
export function startDataCleanup() {
return async (dispatch, getState) => {
@@ -37,7 +40,7 @@ export function loadConfigAndLicense() {
if (currentUserId) {
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
license.IsLicensed === 'true' && license.DataRetention === 'true') {
getDataRetentionPolicy()(dispatch, getState);
dispatch(getDataRetentionPolicy());
} else {
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
}
@@ -79,12 +82,46 @@ 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) {
dispatch(selectTeam({id: teamId}));
actions.push({type: TeamTypes.SELECT_TEAM, data: teamId});
}
dispatch(handleSelectChannel(channelId, true));
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));
}
EphemeralStore.setStartFromNotification(false);
console.log('channel switch from push notification to', channel?.display_name, (Date.now() - dt), 'ms'); //eslint-disable-line
};
}

View File

@@ -1,7 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {TeamTypes} from 'mattermost-redux/action_types';
import {batchActions} from 'redux-batched-actions';
import {ChannelTypes, TeamTypes} from 'mattermost-redux/action_types';
import {getMyTeams} from 'mattermost-redux/actions/teams';
import {RequestStatus} from 'mattermost-redux/constants';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
@@ -18,7 +20,10 @@ export function handleTeamChange(teamId) {
return;
}
dispatch({type: TeamTypes.SELECT_TEAM, data: teamId});
dispatch(batchActions([
{type: TeamTypes.SELECT_TEAM, data: teamId},
{type: ChannelTypes.SELECT_CHANNEL, data: '', extra: {}},
]));
};
}

View File

@@ -1,10 +1,185 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {UserTypes} from 'mattermost-redux/action_types';
import {GeneralTypes, UserTypes} from 'mattermost-redux/action_types';
import {getDataRetentionPolicy} from 'mattermost-redux/actions/general';
import * as HelperActions from 'mattermost-redux/actions/helpers';
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone';
import {Client4} from 'mattermost-redux/client';
import {General} from 'mattermost-redux/constants';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {isTimezoneEnabled} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {setAppCredentials} from 'app/init/credentials';
import {setCSRFFromCookie} from 'app/utils/security';
import {getDeviceTimezoneAsync} from 'app/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();
setCSRFFromCookie(url);
setAppCredentials(deviceToken, user.id, token, url);
// Set timezone
const enableTimezone = isTimezoneEnabled(state);
if (enableTimezone) {
const timezone = await getDeviceTimezoneAsync();
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 loadMe(user, deviceToken) {
return async (dispatch, getState) => {
const state = getState();
const data = {user};
const deviceId = state.entities?.general?.deviceToken;
try {
if (deviceId && !deviceToken) {
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 [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();
dispatch({
type: UserTypes.LOGIN,
data,
});
const roles = new Set();
for (const role of data.user.roles.split(' ')) {
roles.add(role);
}
for (const teamMember of teamMembers) {
for (const role of teamMember.roles.split(' ')) {
roles.add(role);
}
}
if (roles.size > 0) {
dispatch(loadRolesIfNeeded(roles));
}
} 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);
} 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) => {
Client4.setToken(token);
const result = await dispatch(loadMe());
if (!result.error) {
dispatch(completeLogin(result.data.user));
}
return result;
};
}
export function logout(skipServerLogout = false) {
return async (dispatch) => {
if (!skipServerLogout) {
try {
Client4.logout();
} catch {
// Do nothing
}
}
dispatch({type: UserTypes.LOGOUT_SUCCESS});
};
}
export function forceLogoutIfNecessary(error) {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
if (currentUserId && error.status_code === HTTP_UNAUTHORIZED && error.url && !error.url.includes('/login')) {
dispatch(logout(true));
return true;
}
return false;
};
}
export function setCurrentUserStatusOffline() {
return (dispatch, getState) => {
const currentUserId = getCurrentUserId(getState());
@@ -18,3 +193,5 @@ export function setCurrentUserStatusOffline() {
});
};
}
HelperActions.forceLogoutIfNecessary = forceLogoutIfNecessary;

View File

@@ -5,8 +5,7 @@ exports[`SendButton should change theme backgroundColor to 0.3 opacity 1`] = `
style={
Object {
"justifyContent": "flex-end",
"paddingHorizontal": 5,
"paddingVertical": 2,
"paddingRight": 8,
}
}
>
@@ -17,10 +16,9 @@ exports[`SendButton should change theme backgroundColor to 0.3 opacity 1`] = `
"alignItems": "center",
"backgroundColor": "#166de0",
"borderRadius": 4,
"height": 28,
"height": 32,
"justifyContent": "center",
"paddingLeft": 3,
"width": 72,
"width": 80,
},
Object {
"backgroundColor": "rgba(22,109,224,0.3)",
@@ -29,9 +27,9 @@ exports[`SendButton should change theme backgroundColor to 0.3 opacity 1`] = `
}
>
<PaperPlane
color="#ffffff"
height={13}
width={15}
color="rgba(255,255,255,0.5)"
height={16}
width={19}
/>
</View>
</View>
@@ -43,8 +41,7 @@ exports[`SendButton should match snapshot 1`] = `
style={
Object {
"justifyContent": "flex-end",
"paddingHorizontal": 5,
"paddingVertical": 2,
"paddingRight": 8,
}
}
type="opacity"
@@ -55,17 +52,16 @@ exports[`SendButton should match snapshot 1`] = `
"alignItems": "center",
"backgroundColor": "#166de0",
"borderRadius": 4,
"height": 28,
"height": 32,
"justifyContent": "center",
"paddingLeft": 3,
"width": 72,
"width": 80,
}
}
>
<PaperPlane
color="#ffffff"
height={13}
width={15}
height={16}
width={19}
/>
</View>
</TouchableWithFeedbackIOS>
@@ -77,8 +73,7 @@ exports[`SendButton should render theme backgroundColor 1`] = `
style={
Object {
"justifyContent": "flex-end",
"paddingHorizontal": 5,
"paddingVertical": 2,
"paddingRight": 8,
}
}
type="opacity"
@@ -89,17 +84,16 @@ exports[`SendButton should render theme backgroundColor 1`] = `
"alignItems": "center",
"backgroundColor": "#166de0",
"borderRadius": 4,
"height": 28,
"height": 32,
"justifyContent": "center",
"paddingLeft": 3,
"width": 72,
"width": 80,
}
}
>
<PaperPlane
color="#ffffff"
height={13}
width={15}
height={16}
width={19}
/>
</View>
</TouchableWithFeedbackIOS>

View File

@@ -23,7 +23,7 @@ describe('AnnouncementBanner', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<AnnouncementBanner {...baseProps}/>
<AnnouncementBanner {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();

View File

@@ -17,7 +17,7 @@ describe('AtMention', () => {
test('should match snapshot, no highlight', () => {
const wrapper = shallow(
<AtMention {...baseProps}/>
<AtMention {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
@@ -25,7 +25,7 @@ describe('AtMention', () => {
test('should match snapshot, with highlight', () => {
const wrapper = shallow(
<AtMention {...baseProps}/>
<AtMention {...baseProps}/>,
);
wrapper.setState({user: {username: 'John.Smith'}});
@@ -34,7 +34,7 @@ describe('AtMention', () => {
test('should match snapshot, without highlight', () => {
const wrapper = shallow(
<AtMention {...baseProps}/>
<AtMention {...baseProps}/>,
);
wrapper.setState({user: {username: 'Victor.Welch'}});

View File

@@ -21,7 +21,7 @@ import Permissions from 'react-native-permissions';
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {PermissionTypes} from 'app/constants';
import emmProvider from 'app/init/emm_provider';
import {changeOpacity} from 'app/utils/theme';
import {t} from 'app/utils/i18n';
import {showModalOverCurrentContext} from 'app/actions/navigation';
@@ -178,6 +178,7 @@ export default class AttachmentButton extends PureComponent {
if (hasCameraPermission) {
ImagePicker.launchCamera(options, (response) => {
emmProvider.inBackgroundSince = null;
if (response.error || response.didCancel) {
return;
}
@@ -208,10 +209,11 @@ export default class AttachmentButton extends PureComponent {
options.mediaType = 'mixed';
}
const hasPhotoPermission = await this.hasPhotoPermission('photo');
const hasPhotoPermission = await this.hasPhotoPermission('photo', 'photo');
if (hasPhotoPermission) {
ImagePicker.launchImageLibrary(options, (response) => {
emmProvider.inBackgroundSince = null;
if (response.error || response.didCancel) {
return;
}
@@ -244,6 +246,7 @@ export default class AttachmentButton extends PureComponent {
};
ImagePicker.launchImageLibrary(options, (response) => {
emmProvider.inBackgroundSince = null;
if (response.error || response.didCancel) {
return;
}
@@ -259,6 +262,7 @@ export default class AttachmentButton extends PureComponent {
if (hasPermission) {
try {
const res = await DocumentPicker.pick({type: [browseFileTypes]});
emmProvider.inBackgroundSince = null;
if (Platform.OS === 'android') {
// For android we need to retrieve the realPath in case the file being imported is from the cloud
const newUri = await ShareExtension.getFilePath(res.uri);
@@ -283,28 +287,21 @@ export default class AttachmentButton extends PureComponent {
if (Platform.OS === 'ios') {
const {formatMessage} = this.context.intl;
let permissionRequest;
const targetSource = source || 'photo';
const targetSource = source === 'camera' ? Permissions.PERMISSIONS.IOS.CAMERA : Permissions.PERMISSIONS.IOS.PHOTO_LIBRARY;
const hasPermissionToStorage = await Permissions.check(targetSource);
switch (hasPermissionToStorage) {
case PermissionTypes.UNDETERMINED:
case Permissions.RESULTS.DENIED:
permissionRequest = await Permissions.request(targetSource);
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
if (permissionRequest !== Permissions.RESULTS.GRANTED) {
return false;
}
break;
case PermissionTypes.DENIED: {
const canOpenSettings = await Permissions.canOpenSettings();
let grantOption = null;
if (canOpenSettings) {
grantOption = {
text: formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
}),
onPress: () => Permissions.openSettings(),
};
}
case Permissions.RESULTS.BLOCKED: {
const grantOption = {
text: formatMessage({id: 'mobile.permission_denied_retry', defaultMessage: 'Settings'}),
onPress: () => Permissions.openSettings(),
};
const {title, text} = this.getPermissionDeniedMessage(source, mediaType);
@@ -319,7 +316,7 @@ export default class AttachmentButton extends PureComponent {
defaultMessage: 'Don\'t Allow',
}),
},
]
],
);
return false;
}
@@ -332,18 +329,19 @@ export default class AttachmentButton extends PureComponent {
hasStoragePermission = async () => {
if (Platform.OS === 'android') {
const {formatMessage} = this.context.intl;
const storagePermission = Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE;
let permissionRequest;
const hasPermissionToStorage = await Permissions.check('storage');
const hasPermissionToStorage = await Permissions.check(storagePermission);
switch (hasPermissionToStorage) {
case PermissionTypes.UNDETERMINED:
permissionRequest = await Permissions.request('storage');
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
case Permissions.RESULTS.DENIED:
permissionRequest = await Permissions.request(storagePermission);
if (permissionRequest !== Permissions.RESULTS.GRANTED) {
return false;
}
break;
case PermissionTypes.DENIED: {
const {title, text} = this.getPermissionDeniedMessage('storage');
case Permissions.RESULTS.BLOCKED: {
const {title, text} = this.getPermissionDeniedMessage(storagePermission);
Alert.alert(
title,
@@ -362,7 +360,7 @@ export default class AttachmentButton extends PureComponent {
}),
onPress: () => AndroidOpenSettings.appDetailsSettings(),
},
]
],
);
return false;
}

View File

@@ -3,13 +3,13 @@
import React from 'react';
import {shallow} from 'enzyme';
import Permissions from 'react-native-permissions';
import {Alert} from 'react-native';
import Preferences from 'mattermost-redux/constants/preferences';
import {VALID_MIME_TYPES} from 'app/screens/edit_profile/edit_profile';
import {PermissionTypes} from 'app/constants';
import AttachmentButton from './index';
@@ -28,9 +28,7 @@ describe('AttachmentButton', () => {
};
test('should match snapshot', () => {
const wrapper = shallow(
<AttachmentButton {...baseProps}/>
);
const wrapper = shallow(<AttachmentButton {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
@@ -42,9 +40,7 @@ describe('AttachmentButton', () => {
onShowUnsupportedMimeTypeWarning: jest.fn(),
};
const wrapper = shallow(
<AttachmentButton {...props}/>
);
const wrapper = shallow(<AttachmentButton {...props}/>);
const file = {
type: 'image/gif',
@@ -63,9 +59,7 @@ describe('AttachmentButton', () => {
onShowUnsupportedMimeTypeWarning: jest.fn(),
};
const wrapper = shallow(
<AttachmentButton {...props}/>
);
const wrapper = shallow(<AttachmentButton {...props}/>);
const file = {
fileSize: 10,
@@ -79,11 +73,24 @@ describe('AttachmentButton', () => {
});
});
test('should show permission denied alert if permission is denied in iOS', async () => {
expect.assertions(1);
test('should return permission false if permission is denied in iOS', async () => {
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.DENIED);
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
jest.spyOn(Permissions, 'check').mockReturnValue(PermissionTypes.DENIED);
jest.spyOn(Permissions, 'canOpenSettings').mockReturnValue(true);
const wrapper = shallow(
<AttachmentButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
expect(Permissions.check).toHaveBeenCalled();
expect(Permissions.request).toHaveBeenCalled();
expect(Alert.alert).not.toHaveBeenCalled();
expect(hasPhotoPermission).toBe(false);
});
test('should show permission denied alert and return permission false if permission is blocked in iOS', async () => {
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
jest.spyOn(Alert, 'alert').mockReturnValue(true);
const wrapper = shallow(
@@ -91,7 +98,10 @@ describe('AttachmentButton', () => {
{context: {intl: {formatMessage}}},
);
await wrapper.instance().hasPhotoPermission('camera');
expect(Alert.alert).toBeCalled();
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
expect(Permissions.check).toHaveBeenCalled();
expect(Permissions.request).not.toHaveBeenCalled();
expect(Alert.alert).toHaveBeenCalled();
expect(hasPhotoPermission).toBe(false);
});
});

View File

@@ -94,13 +94,18 @@ export default class EmojiSuggestion extends PureComponent {
const results = await fuse.search(matchTerm.toLowerCase());
const data = results.map((index) => emojis[index]);
this.setEmojiData(data);
this.setEmojiData(data, matchTerm);
};
setEmojiData = (data) => {
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(compareEmojis),
dataSource: data.sort(sorter),
});
this.props.onResultCountChange(data.length);

View File

@@ -24,7 +24,7 @@ const getEmojisByName = createSelector(
}
return Array.from(emoticons);
}
},
);
function mapStateToProps(state) {

View File

@@ -23,13 +23,12 @@ const mobileCommandsSelector = createSelector(
getAutocompleteCommandsList,
(commands) => {
return commands.filter((command) => !COMMANDS_TO_HIDE_ON_MOBILE.includes(command.trigger));
}
},
);
function mapStateToProps(state) {
return {
commands: mobileCommandsSelector(state),
commandsRequest: state.requests.integrations.getAutocompleteCommands,
currentTeamId: getCurrentTeamId(state),
theme: getTheme(state),
isLandscape: isLandscape(state),

View File

@@ -1,15 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {Component} from 'react';
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
FlatList,
Platform,
} from 'react-native';
import {RequestStatus} from 'mattermost-redux/constants';
import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divider';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
@@ -18,14 +16,13 @@ import SlashSuggestionItem from './slash_suggestion_item';
const SLASH_REGEX = /(^\/)([a-zA-Z-]*)$/;
const TIME_BEFORE_NEXT_COMMAND_REQUEST = 1000 * 60 * 5;
export default class SlashSuggestion extends Component {
export default class SlashSuggestion extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
getAutocompleteCommands: PropTypes.func.isRequired,
}).isRequired,
currentTeamId: PropTypes.string.isRequired,
commands: PropTypes.array,
commandsRequest: PropTypes.object.isRequired,
isSearch: PropTypes.bool,
maxListHeight: PropTypes.number,
theme: PropTypes.object.isRequired,
@@ -56,7 +53,6 @@ export default class SlashSuggestion extends Component {
const {currentTeamId} = this.props;
const {
commands: nextCommands,
commandsRequest: nextCommandsRequest,
currentTeamId: nextTeamId,
value: nextValue,
} = nextProps;
@@ -81,7 +77,7 @@ export default class SlashSuggestion extends Component {
const dataIsStale = Date.now() - this.state.lastCommandRequest > TIME_BEFORE_NEXT_COMMAND_REQUEST;
if ((!nextCommands.length || dataIsStale) && nextCommandsRequest.status !== RequestStatus.STARTED) {
if ((!nextCommands.length || dataIsStale)) {
this.props.actions.getAutocompleteCommands(nextProps.currentTeamId);
this.setState({
lastCommandRequest: Date.now(),

View File

@@ -18,7 +18,7 @@ describe('Badge', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<Badge {...baseProps}/>
<Badge {...baseProps}/>,
);
expect(wrapper.instance().renderText()).toMatchSnapshot();

View File

@@ -351,7 +351,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
marginBottom: 12,
},
container: {
marginTop: 60,
marginTop: 10,
marginHorizontal: 12,
marginBottom: 12,
overflow: 'hidden',

View File

@@ -24,7 +24,7 @@ function makeMapStateToProps() {
(currentUserId, profilesInChannel) => {
const currentChannelMembers = profilesInChannel || [];
return currentChannelMembers.filter((m) => m.id !== currentUserId);
}
},
);
return function mapStateToProps(state, ownProps) {

View File

@@ -24,7 +24,7 @@ function makeGetChannelNamesMap() {
}
return channelsNameMap;
}
},
);
}

View File

@@ -93,7 +93,7 @@ export default class ChannelLoader extends PureComponent {
stopLoadingAnimation = () => {
Animated.timing(
this.state.barsOpacity
this.state.barsOpacity,
).stop();
}

View File

@@ -130,7 +130,7 @@ export default class ClientUpgradeListener extends PureComponent {
intl.formatMessage({
id: 'mobile.client_upgrade.download_error.message',
defaultMessage: 'An error occurred while trying to open the download link.',
})
}),
);
return false;

View File

@@ -1,5 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-confusing-arrow */
export const ConditionalWrapper = ({conditional, wrapper, children}) => conditional ? wrapper(children) : children;

View File

@@ -29,7 +29,7 @@ describe('CustomList', () => {
test('should match snapshot with FlatList', () => {
const wrapper = shallow(
<CustomList {...baseProps}/>
<CustomList {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find('FlatList')).toHaveLength(1);
@@ -42,7 +42,7 @@ describe('CustomList', () => {
};
const wrapper = shallow(
<CustomList {...props}/>
<CustomList {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find('SectionList')).toHaveLength(1);
@@ -50,7 +50,7 @@ describe('CustomList', () => {
test('should match snapshot, renderSectionHeader', () => {
const wrapper = shallow(
<CustomList {...baseProps}/>
<CustomList {...baseProps}/>,
);
const section = {
id: 'section_id',
@@ -61,7 +61,7 @@ describe('CustomList', () => {
test('should call props.renderItem on renderItem', () => {
const props = {...baseProps};
const wrapper = shallow(
<CustomList {...props}/>
<CustomList {...props}/>,
);
wrapper.instance().renderItem({item: {id: 'item_id', selected: true}, index: 0, section: null});
expect(props.renderItem).toHaveBeenCalledTimes(1);
@@ -69,7 +69,7 @@ describe('CustomList', () => {
test('should match snapshot, renderSeparator', () => {
const wrapper = shallow(
<CustomList {...baseProps}/>
<CustomList {...baseProps}/>,
);
expect(wrapper.instance().renderSeparator()).toMatchSnapshot();
});
@@ -77,7 +77,7 @@ describe('CustomList', () => {
test('should match snapshot, renderFooter', () => {
const props = {...baseProps};
const wrapper = shallow(
<CustomList {...props}/>
<CustomList {...props}/>,
);
// should return null

View File

@@ -37,7 +37,7 @@ describe('EditChannelInfo', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<EditChannelInfo {...baseProps}/>
<EditChannelInfo {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
@@ -45,7 +45,7 @@ describe('EditChannelInfo', () => {
test('should have called onHeaderChangeText on text change from Autocomplete', () => {
const wrapper = shallow(
<EditChannelInfo {...baseProps}/>
<EditChannelInfo {...baseProps}/>,
);
const instance = wrapper.instance();
@@ -67,7 +67,7 @@ describe('EditChannelInfo', () => {
test('should call scrollHeaderToTop', () => {
const wrapper = shallow(
<EditChannelInfo {...baseProps}/>
<EditChannelInfo {...baseProps}/>,
);
const instance = wrapper.instance();

View File

@@ -4,7 +4,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Image,
Platform,
StyleSheet,
Text,
@@ -12,7 +11,6 @@ import {
import FastImage from 'react-native-fast-image';
import CustomPropTypes from 'app/constants/custom_prop_types';
import ImageCacheManager from 'app/utils/image_cache_manager';
export default class Emoji extends React.PureComponent {
static propTypes = {
@@ -50,56 +48,15 @@ export default class Emoji extends React.PureComponent {
isCustomEmoji: false,
};
constructor(props) {
super(props);
this.state = {
imageUrl: null,
};
}
componentDidMount() {
const {displayTextOnly, emojiName, imageUrl} = this.props;
this.mounted = true;
if (!displayTextOnly && imageUrl) {
ImageCacheManager.cache(`emoji-${emojiName}`, imageUrl, this.setImageUrl);
}
}
componentWillReceiveProps(nextProps) {
const {displayTextOnly, emojiName, imageUrl} = nextProps;
if (emojiName !== this.props.emojiName && this.mounted) {
this.setState({
imageUrl: null,
});
}
if (!displayTextOnly && imageUrl &&
imageUrl !== this.props.imageUrl) {
ImageCacheManager.cache(`emoji-${emojiName}`, imageUrl, this.setImageUrl);
}
}
componentWillUnmount() {
this.mounted = false;
}
setImageUrl = (imageUrl) => {
if (this.mounted) {
this.setState({
imageUrl,
});
}
};
render() {
const {
literal,
textStyle,
displayTextOnly,
customEmojiStyle,
displayTextOnly,
imageUrl,
literal,
unicode,
textStyle,
} = this.props;
const {imageUrl} = this.state;
let size = this.props.size;
let fontSize = size;
@@ -118,28 +75,23 @@ export default class Emoji extends React.PureComponent {
// Android can't change the size of an image after its first render, so
// force a new image to be rendered when the size changes
const key = Platform.OS === 'android' ? (height + '-' + width) : null;
const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null;
if (this.props.unicode && !this.props.imageUrl) {
const codeArray = this.props.unicode.split('-');
if (unicode && !imageUrl) {
const codeArray = unicode.split('-');
const code = codeArray.reduce((acc, c) => {
return acc + String.fromCodePoint(parseInt(c, 16));
}, '');
return (
<Text style={[this.props.textStyle, {fontSize: size}]}>
<Text style={[textStyle, {fontSize: size}]}>
{code}
</Text>
);
}
if (!imageUrl) {
return (
<Image
key={key}
style={{width, height}}
/>
);
return null;
}
return (
@@ -148,6 +100,7 @@ export default class Emoji extends React.PureComponent {
style={[customEmojiStyle, {width, height}]}
source={{uri: imageUrl}}
onError={this.onError}
resizeMode={FastImage.resizeMode.contain}
/>
);
}

View File

@@ -24,6 +24,7 @@ import {
makeStyleSheetFromTheme,
changeOpacity,
} from 'app/utils/theme';
import {compareEmojis} from 'app/utils/emoji_utils';
import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing';
import EmojiPickerRow from './emoji_picker_row';
@@ -211,7 +212,9 @@ export default class EmojiPicker extends PureComponent {
}
const results = fuse.search(searchTermLowerCase);
const data = results.map((index) => emojis[index]);
const sorter = (a, b) => compareEmojis(a, b, searchTerm);
const data = results.map((index) => emojis[index]).sort(sorter);
return data;
};

View File

@@ -125,7 +125,7 @@ const getEmojisBySection = createSelector(
}
return emoticons;
}
},
);
const getEmojisByName = createSelector(
@@ -137,7 +137,7 @@ const getEmojisByName = createSelector(
}
return Array.from(emoticons);
}
},
);
function mapStateToProps(state) {

View File

@@ -31,7 +31,7 @@ export default class Fade extends PureComponent {
toValue: prevProps.visible ? 0 : 1,
duration: this.props.duration || FADE_DURATION,
useNativeDriver: true,
}
},
).start();
}
}

View File

@@ -24,7 +24,7 @@ describe('Fade', () => {
{...props}
>
<Text>{dummyText}</Text>
</Fade>
</Fade>,
);
}

View File

@@ -37,7 +37,7 @@ describe('FileAttachment', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<FileAttachment {...baseProps}/>
<FileAttachment {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();

View File

@@ -146,7 +146,7 @@ export default class FileAttachmentDocument extends PureComponent {
this.setState({downloading: true});
this.downloadTask = RNFetchBlob.config(options).fetch('GET', getFileUrl(data.id));
this.downloadTask.progress((received, total) => {
const progress = (received / total) * 100;
const progress = Math.round((received / total) * 100);
if (this.mounted) {
this.setState({progress});
}
@@ -216,7 +216,9 @@ export default class FileAttachmentDocument extends PureComponent {
};
onDonePreviewingFile = () => {
this.setState({preview: false});
if (this.mounted) {
this.setState({preview: false});
}
this.setStatusBarColor();
};
@@ -256,13 +258,15 @@ export default class FileAttachmentDocument extends PureComponent {
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}]
}],
);
this.onDonePreviewingFile();
RNFetchBlob.fs.unlink(path);
}
this.setState({downloading: false, progress: 0});
if (this.mounted) {
this.setState({downloading: false, progress: 0});
}
});
// Android does not trigger the event for DoneButtonEvent
@@ -281,7 +285,11 @@ export default class FileAttachmentDocument extends PureComponent {
didCancel: true,
}, () => {
// need to wait a bit for the progress circle UI to update to the give progress
setTimeout(() => this.setState({downloading: false}), 2000);
setTimeout(() => {
if (this.mounted) {
this.setState({downloading: false});
}
}, 2000);
});
}
};
@@ -303,7 +311,7 @@ export default class FileAttachmentDocument extends PureComponent {
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}]
}],
);
};
@@ -324,7 +332,7 @@ export default class FileAttachmentDocument extends PureComponent {
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}]
}],
);
};

View File

@@ -8,13 +8,12 @@ import {
View,
StyleSheet,
} from 'react-native';
import FastImage from 'react-native-fast-image';
import {Client4} from 'mattermost-redux/client';
import ProgressiveImage from 'app/components/progressive_image';
import {isGif} from 'app/utils/file';
import {emptyFunction} from 'app/utils/general';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {changeOpacity} from 'app/utils/theme';
import thumb from 'assets/images/thumb.png';
@@ -58,11 +57,14 @@ export default class FileAttachmentImage extends PureComponent {
const {file} = props;
if (file && file.id) {
ImageCacheManager.cache(file.name, Client4.getFileThumbnailUrl(file.id), emptyFunction);
const headers = {Authorization: `Bearer ${Client4.getToken()}`};
const preloadImages = [{uri: Client4.getFileThumbnailUrl(file.id), headers}];
if (isGif(file)) {
ImageCacheManager.cache(file.name, Client4.getFileUrl(file.id), emptyFunction);
preloadImages.push({uri: Client4.getFileUrl(file.id), headers});
}
FastImage.preload(preloadImages);
}
this.state = {
@@ -186,13 +188,8 @@ const style = StyleSheet.create({
smallImageOverlay: {
...StyleSheet.absoluteFill,
justifyContent: 'center',
borderRadius: 4,
},
loaderContainer: {
position: 'absolute',
height: '100%',
width: '100%',
alignItems: 'center',
borderRadius: 4,
},
singleSmallImageWrapper: {
height: SMALL_IMAGE_MAX_HEIGHT,

View File

@@ -13,10 +13,8 @@ import {TABLET_WIDTH} from 'app/components/sidebars/drawer_layout';
import {DeviceTypes} from 'app/constants';
import mattermostManaged from 'app/mattermost_managed';
import {isDocument, isGif, isVideo} from 'app/utils/file';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {previewImageAtIndex} from 'app/utils/images';
import {preventDoubleTap} from 'app/utils/tap';
import {emptyFunction} from 'app/utils/general';
import FileAttachment from './file_attachment';
@@ -123,9 +121,9 @@ export default class FileAttachmentList extends PureComponent {
if (file.localPath) {
uri = file.localPath;
} else if (isGif(file)) {
uri = await ImageCacheManager.cache(file.name, Client4.getFileUrl(file.id), emptyFunction); // eslint-disable-line no-await-in-loop
uri = Client4.getFileUrl(file.id);
} else {
uri = await ImageCacheManager.cache(file.name, Client4.getFilePreviewUrl(file.id), emptyFunction); // eslint-disable-line no-await-in-loop
uri = Client4.getFilePreviewUrl(file.id);
}
results.push({

View File

@@ -3,7 +3,7 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Platform, StyleSheet, Text, View} from 'react-native';
import {Text, View} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import {AnimatedCircularProgress} from 'react-native-circular-progress';
@@ -17,6 +17,7 @@ import mattermostBucket from 'app/mattermost_bucket';
import {buildFileUploadData, encodeHeaderURIStringToUTF8} from 'app/utils/file';
import {emptyFunction} from 'app/utils/general';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
export default class FileUploadItem extends PureComponent {
static propTypes = {
@@ -163,15 +164,14 @@ export default class FileUploadItem extends PureComponent {
};
renderProgress = (fill) => {
const styles = getStyleSheet(this.props.theme);
const realFill = Number(fill.toFixed(0));
return (
<View style={styles.progressContent}>
<View style={styles.progressCirclePercentage}>
<Text style={styles.progressText}>
{`${realFill}%`}
</Text>
</View>
<View>
<Text style={styles.progressText}>
{`${realFill}%`}
</Text>
</View>
);
};
@@ -184,23 +184,28 @@ export default class FileUploadItem extends PureComponent {
theme,
} = this.props;
const {progress} = this.state;
const styles = getStyleSheet(theme);
let filePreviewComponent;
if (this.isImageType()) {
filePreviewComponent = (
<FileAttachmentImage
file={file}
theme={theme}
/>
<View style={styles.filePreview}>
<FileAttachmentImage
file={file}
theme={theme}
/>
</View>
);
} else {
filePreviewComponent = (
<FileAttachmentIcon
file={file}
theme={theme}
wrapperHeight={100}
wrapperWidth={100}
/>
<View style={styles.filePreview}>
<FileAttachmentIcon
file={file}
theme={theme}
wrapperHeight={53}
wrapperWidth={53}
/>
</View>
);
}
@@ -209,7 +214,7 @@ export default class FileUploadItem extends PureComponent {
key={file.clientId}
style={styles.preview}
>
<View style={styles.previewShadow}>
<View style={styles.previewContainer}>
{filePreviewComponent}
{file.failed &&
<FileUploadRetry
@@ -220,13 +225,12 @@ export default class FileUploadItem extends PureComponent {
{file.loading && !file.failed &&
<View style={styles.progressCircleContent}>
<AnimatedCircularProgress
size={100}
size={36}
fill={progress}
width={4}
width={2}
backgroundColor='rgba(255, 255, 255, 0.5)'
tintColor='white'
rotation={0}
style={styles.progressCircle}
>
{this.renderProgress}
</AnimatedCircularProgress>
@@ -245,59 +249,35 @@ export default class FileUploadItem extends PureComponent {
}
}
const styles = StyleSheet.create({
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
preview: {
justifyContent: 'flex-end',
height: 115,
width: 115,
paddingTop: 5,
marginLeft: 12,
},
previewShadow: {
height: 100,
width: 100,
elevation: 10,
borderRadius: 5,
...Platform.select({
ios: {
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.5,
shadowRadius: 4,
shadowOffset: {
width: 0,
height: 0,
},
},
}),
},
progressCircle: {
alignItems: 'center',
height: '100%',
justifyContent: 'center',
width: '100%',
previewContainer: {
height: 56,
width: 56,
borderRadius: 4,
},
progressCircleContent: {
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.4)',
height: 100,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
height: 56,
width: 56,
justifyContent: 'center',
position: 'absolute',
width: 100,
},
progressCirclePercentage: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
progressContent: {
alignItems: 'center',
height: '100%',
justifyContent: 'center',
left: 0,
position: 'absolute',
width: '100%',
borderRadius: 4,
},
progressText: {
color: 'white',
fontSize: 18,
fontSize: 11,
fontWeight: 'bold',
},
});
filePreview: {
borderColor: changeOpacity(theme.centerChannelColor, 0.15),
borderRadius: 4,
borderWidth: 1,
width: 56,
height: 56,
},
}));

View File

@@ -3,6 +3,7 @@
import React from 'react';
import {shallow} from 'enzyme';
import {Preferences} from 'mattermost-redux/constants';
import ImageCacheManager from 'app/utils/image_cache_manager';
import FileUploadItem from './file_upload_item';
@@ -18,7 +19,7 @@ describe('FileUploadItem', () => {
file: {
loading: false,
},
theme: {},
theme: Preferences.THEMES.default,
};
describe('downloadAndUploadFile', () => {

View File

@@ -4,19 +4,25 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Platform,
InteractionManager,
ScrollView,
Text,
View,
Platform,
} from 'react-native';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import * as Animatable from 'react-native-animatable';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import FormattedText from 'app/components/formatted_text';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import FileUploadItem from './file_upload_item';
const showFiles = {opacity: 1, height: 68};
const hideFiles = {opacity: 0, height: 0};
const hideError = {height: 0};
export default class FileUploadPreview extends PureComponent {
static propTypes = {
channelId: PropTypes.string.isRequired,
@@ -36,9 +42,17 @@ export default class FileUploadPreview extends PureComponent {
showFileMaxWarning: false,
};
errorRef = React.createRef();
errorContainerRef = React.createRef();
containerRef = React.createRef();
componentDidMount() {
EventEmitter.on('fileMaxWarning', this.handleFileMaxWarning);
EventEmitter.on('fileSizeWarning', this.handleFileSizeWarning);
if (this.props.files.length) {
InteractionManager.runAfterInteractions(this.showOrHideContainer);
}
}
componentWillUnmount() {
@@ -46,6 +60,12 @@ export default class FileUploadPreview extends PureComponent {
EventEmitter.off('fileSizeWarning', this.handleFileSizeWarning);
}
componentDidUpdate(prevProps) {
if (this.containerRef.current && this.props.files.length !== prevProps.files.length) {
InteractionManager.runAfterInteractions(this.showOrHideContainer);
}
}
buildFilePreviews = () => {
return this.props.files.map((file) => {
return (
@@ -60,35 +80,77 @@ export default class FileUploadPreview extends PureComponent {
});
};
clearErrorsFromState = (delay) => {
setTimeout(() => {
this.setState({
showFileMaxWarning: false,
fileSizeWarning: null,
});
}, delay || 0);
}
handleFileMaxWarning = () => {
this.setState({showFileMaxWarning: true});
setTimeout(() => {
this.setState({showFileMaxWarning: false});
}, 3000);
if (this.errorRef.current) {
this.makeErrorVisible(true, 20);
setTimeout(() => {
this.makeErrorVisible(false, 20);
}, 5000);
}
};
handleFileSizeWarning = (message) => {
this.setState({fileSizeWarning: message});
if (this.errorRef.current) {
if (message) {
this.setState({fileSizeWarning: message.replace(': ', ':\n')});
this.makeErrorVisible(true, 40);
} else {
this.makeErrorVisible(false, 20);
}
}
};
render() {
makeErrorVisible = (visible, height) => {
if (this.errorContainerRef.current) {
if (visible) {
this.errorContainerRef.current.transition(hideError, {height}, 200, 'ease-out');
} else {
this.errorContainerRef.current.transition({height}, hideError, 200, 'ease-in');
}
}
}
showOrHideContainer = () => {
const {
channelIsLoading,
filesUploadingForCurrentChannel,
files,
} = this.props;
const {fileSizeWarning, showFileMaxWarning} = this.state;
const style = getStyleSheet(this.props.theme);
if (
!fileSizeWarning && !showFileMaxWarning &&
(channelIsLoading || (!files.length && !filesUploadingForCurrentChannel))
) {
return null;
if ((channelIsLoading || (!files.length && !filesUploadingForCurrentChannel))) {
this.containerRef.current.transition(showFiles, hideFiles, 150, 'ease-out');
this.shown = false;
} else if (files.length && !this.shown) {
this.containerRef.current.transition(hideFiles, showFiles, 350, 'ease-in');
this.shown = true;
}
}
render() {
const {fileSizeWarning, showFileMaxWarning} = this.state;
const {theme, files} = this.props;
const style = getStyleSheet(theme);
const fileContainerStyle = {
paddingBottom: files.length ? 5 : 0,
};
return (
<View style={style.previewContainer}>
<View style={style.fileContainer}>
<Animatable.View
style={[style.fileContainer, fileContainerStyle]}
ref={this.containerRef}
isInteraction={true}
>
<ScrollView
horizontal={true}
style={style.scrollView}
@@ -97,21 +159,32 @@ export default class FileUploadPreview extends PureComponent {
>
{this.buildFilePreviews()}
</ScrollView>
</View>
<View style={style.errorContainer}>
{showFileMaxWarning && (
<FormattedText
style={style.warning}
id='mobile.file_upload.max_warning'
defaultMessage='Uploads limited to 5 files maximum.'
/>
)}
{Boolean(fileSizeWarning) &&
<Text style={style.warning}>
{fileSizeWarning}
</Text>
}
</View>
</Animatable.View>
<Animatable.View
ref={this.errorContainerRef}
style={style.errorContainer}
isInteraction={true}
>
<Animatable.View
ref={this.errorRef}
isInteraction={true}
style={style.errorTextContainer}
useNativeDriver={true}
>
{showFileMaxWarning && (
<FormattedText
style={style.warning}
id='mobile.file_upload.max_warning'
defaultMessage='Uploads limited to 5 files maximum.'
/>
)}
{Boolean(fileSizeWarning) &&
<Text style={style.warning}>
{fileSizeWarning}
</Text>
}
</Animatable.View>
</Animatable.View>
</View>
);
}
@@ -119,32 +192,37 @@ export default class FileUploadPreview extends PureComponent {
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
fileContainer: {
display: 'flex',
flexDirection: 'row',
},
errorContainer: {
height: 18,
},
previewContainer: {
display: 'flex',
flexDirection: 'column',
},
fileContainer: {
display: 'flex',
flexDirection: 'row',
height: 0,
},
errorContainer: {
height: 0,
},
errorTextContainer: {
marginTop: Platform.select({
ios: 4,
android: 2,
}),
marginHorizontal: 12,
flex: 1,
},
scrollView: {
flex: 1,
marginBottom: 10,
},
scrollViewContent: {
alignItems: 'flex-end',
marginLeft: 14,
paddingRight: 12,
},
warning: {
color: theme.errorTextColor,
marginLeft: 14,
marginBottom: Platform.select({
android: 14,
ios: 0,
}),
flex: 1,
flexWrap: 'wrap',
},
};
});

View File

@@ -3,9 +3,9 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Platform} from 'react-native';
import {View, Platform} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
@@ -25,19 +25,22 @@ export default class FileUploadRemove extends PureComponent {
};
render() {
const style = getStyleSheet(this.props.theme);
const {theme} = this.props;
const style = getStyleSheet(theme);
return (
<TouchableWithFeedback
style={style.removeButtonWrapper}
style={style.tappableContainer}
onPress={this.handleOnPress}
type={'opacity'}
>
<Icon
name='close-circle'
color={this.props.theme.centerChannelColor}
size={20}
style={style.removeButtonIcon}
/>
<View style={style.removeButton}>
<Icon
name='close-circle'
color={changeOpacity(theme.centerChannelColor, 0.64)}
size={21}
style={style.removeIcon}
/>
</View>
</TouchableWithFeedback>
);
}
@@ -45,25 +48,29 @@ export default class FileUploadRemove extends PureComponent {
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
removeButtonIcon: Platform.select({
ios: {
marginTop: 2,
},
}),
removeButtonWrapper: {
alignItems: 'center',
justifyContent: 'center',
tappableContainer: {
position: 'absolute',
overflow: 'hidden',
elevation: 11,
top: 7,
right: 7,
width: 24,
height: 24,
top: -5,
right: -10,
width: 32,
height: 32,
},
removeButton: {
borderRadius: 12,
alignSelf: 'center',
marginTop: Platform.select({
ios: 5.4,
android: 4.75,
}),
backgroundColor: theme.centerChannelBg,
borderWidth: 2,
borderColor: theme.centerChannelBg,
},
removeIcon: {
position: 'relative',
top: Platform.select({
ios: 1,
android: 0,
}),
},
};
});

View File

@@ -29,7 +29,7 @@ export default class FileUploadRetry extends PureComponent {
>
<Icon
name='md-refresh'
size={50}
size={25}
color='#fff'
/>
</TouchableWithFeedback>
@@ -45,5 +45,6 @@ const style = StyleSheet.create({
width: '100%',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
},
});

View File

@@ -4,19 +4,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Text} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
import moment from 'moment-timezone';
import CustomPropTypes from 'app/constants/custom_prop_types';
class FormattedTime extends React.PureComponent {
export default class FormattedTime extends React.PureComponent {
static propTypes = {
value: PropTypes.any.isRequired,
timeZone: PropTypes.string,
children: PropTypes.func,
hour12: PropTypes.bool,
style: CustomPropTypes.Style,
intl: intlShape.isRequired,
};
getFormattedTime = () => {
@@ -24,23 +22,14 @@ class FormattedTime extends React.PureComponent {
value,
timeZone,
hour12,
intl,
} = this.props;
const timezoneProps = timeZone ? {timeZone} : {};
const options = {
...timezoneProps,
hour12,
};
const formattedTime = intl.formatTime(value, options);
// `formatTime` returns unformatted date string on error like in the case of (react-intl) unsupported timezone.
// Therefore, use react-intl by default or moment-timezone for unsupported timezone.
if (formattedTime !== String(new Date(value))) {
return formattedTime;
let format = 'H:mm';
if (hour12) {
const localeFormat = moment.localeData().longDateFormat('LT');
format = localeFormat?.includes('A') ? localeFormat : 'h:mm A';
}
const format = hour12 ? 'hh:mm A' : 'HH:mm';
if (timeZone) {
return moment.tz(value, timeZone).format(format);
}
@@ -59,5 +48,3 @@ class FormattedTime extends React.PureComponent {
return <Text style={style}>{formattedTime}</Text>;
}
}
export default injectIntl(FormattedTime);

View File

@@ -4,9 +4,7 @@
import React from 'react';
import {render} from '@testing-library/react-native';
import {IntlProvider} from 'react-intl';
import IntlPolyfill from 'intl';
import 'intl/locale-data/jsonp/es';
import 'intl/locale-data/jsonp/ko';
import moment from 'moment-timezone';
import FormattedTime from './formatted_time';
@@ -17,13 +15,11 @@ describe('FormattedTime', () => {
hour12: true,
};
setupTest();
it('should render correctly', () => {
console.error = jest.fn();
let wrapper = renderWithIntl(
<FormattedTime {...baseProps}/>
<FormattedTime {...baseProps}/>,
);
expect(wrapper.baseElement).toMatchSnapshot();
@@ -33,20 +29,22 @@ describe('FormattedTime', () => {
<FormattedTime
{...baseProps}
hour12={false}
/>
/>,
);
expect(wrapper.getByText('19:02')).toBeTruthy();
});
it('should support localization', () => {
moment.locale('es');
let wrapper = renderWithIntl(
<FormattedTime {...baseProps}/>,
'es',
);
expect(wrapper.getByText('7:02 p. m.')).toBeTruthy();
expect(wrapper.getByText('7:02 PM')).toBeTruthy();
moment.locale('ko');
wrapper = renderWithIntl(
<FormattedTime {...baseProps}/>,
'ko',
@@ -66,6 +64,7 @@ describe('FormattedTime', () => {
});
it('should fallback to default short format for unsupported locale of react-intl ', () => {
moment.locale('es');
let wrapper = renderWithIntl(
<FormattedTime
{...baseProps}
@@ -74,24 +73,21 @@ describe('FormattedTime', () => {
'es',
);
expect(wrapper.getByText('08:47 AM')).toBeTruthy();
expect(wrapper.getByText('8:47 AM')).toBeTruthy();
wrapper = renderWithIntl(
<FormattedTime
{...baseProps}
timeZone='NZ-CHAT'
hour12={false}
/>
/>,
'es',
);
expect(wrapper.getByText('08:47')).toBeTruthy();
expect(wrapper.getByText('8:47')).toBeTruthy();
});
});
function renderWithIntl(component, locale = 'en') {
return render(<IntlProvider locale={locale}>{component}</IntlProvider>);
}
function setupTest() {
global.Intl = IntlPolyfill;
}

View File

@@ -6,6 +6,7 @@ import React from 'react';
import {intlShape} from 'react-intl';
import {
Clipboard,
Keyboard,
StyleSheet,
Text,
View,
@@ -57,7 +58,7 @@ export default class MarkdownCodeBlock extends React.PureComponent {
},
{
language: languageDisplayName,
}
},
);
} else {
title = intl.formatMessage({
@@ -66,7 +67,10 @@ export default class MarkdownCodeBlock extends React.PureComponent {
});
}
goToScreen(screen, title, passProps);
Keyboard.dismiss();
requestAnimationFrame(() => {
goToScreen(screen, title, passProps);
});
});
handleLongPress = async () => {

View File

@@ -19,7 +19,7 @@ describe('MarkdownEmoji', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<MarkdownEmoji {...baseProps}/>
<MarkdownEmoji {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();

View File

@@ -21,7 +21,6 @@ import CustomPropTypes from 'app/constants/custom_prop_types';
import EphemeralStore from 'app/store/ephemeral_store';
import mattermostManaged from 'app/mattermost_managed';
import BottomSheet from 'app/utils/bottom_sheet';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {previewImageAtIndex, calculateDimensions, isGifTooLarge} from 'app/utils/images';
import {normalizeProtocol} from 'app/utils/url';
@@ -32,7 +31,7 @@ const ANDROID_MAX_WIDTH = 4096;
const VIEWPORT_IMAGE_OFFSET = 66;
const VIEWPORT_IMAGE_REPLY_OFFSET = 13;
export default class MarkdownImage extends React.Component {
export default class MarkdownImage extends React.PureComponent {
static propTypes = {
children: PropTypes.node,
deviceHeight: PropTypes.number.isRequired,
@@ -65,7 +64,7 @@ export default class MarkdownImage extends React.Component {
componentDidMount() {
this.mounted = true;
ImageCacheManager.cache(null, this.getSource(), this.setImageUrl);
this.setImageUrl(this.getSource());
}
static getDerivedStateFromProps(props) {
@@ -84,7 +83,7 @@ export default class MarkdownImage extends React.Component {
componentDidUpdate(prevProps) {
if (this.props.source !== prevProps.source) {
// getSource also depends on serverURL, but that shouldn't change while this is mounted
ImageCacheManager.cache(null, this.getSource(), this.setImageUrl);
this.setImageUrl(this.getSource());
}
}

View File

@@ -12,10 +12,10 @@ import {DeepLinkTypes} from 'app/constants';
import {getCurrentServerUrl} from 'app/init/credentials';
import mattermostManaged from 'app/mattermost_managed';
import BottomSheet from 'app/utils/bottom_sheet';
import {alertErrorWithFallback} from 'app/utils/general';
import {t} from 'app/utils/i18n';
import {preventDoubleTap} from 'app/utils/tap';
import {matchDeepLink, normalizeProtocol} from 'app/utils/url';
import {alertErrorWithFallback} from 'app/utils/general';
import {t} from 'app/utils/i18n';
import Config from 'assets/config';
@@ -58,16 +58,7 @@ export default class MarkdownLink extends PureComponent {
if (match) {
if (match.type === DeepLinkTypes.CHANNEL) {
const error = await this.props.actions.handleSelectChannelByName(match.channelName, match.teamName);
if (error) {
const linkFailedMessage = {
id: t('mobile.server_link.private_channel.error'),
defaultMessage: 'You are not a member of this private channel.',
};
alertErrorWithFallback(this.context.intl, {}, linkFailedMessage);
}
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, this.errorBadChannel);
} else if (match.type === DeepLinkTypes.PERMALINK) {
onPermalinkPress(match.postId, match.teamName);
}
@@ -92,6 +83,16 @@ export default class MarkdownLink extends PureComponent {
}
});
errorBadChannel = () => {
const {intl} = this.context;
const message = {
id: t('mobile.server_link.unreachable_channel.error'),
defaultMessage: 'This link belongs to a deleted channel or to a channel to which you do not have access.',
};
alertErrorWithFallback(intl, {}, message);
};
parseLinkLiteral = (literal) => {
let nextLiteral = literal;

View File

@@ -30,13 +30,14 @@ exports[`MarkdownTable should match snapshot 1`] = `
"width": "100%",
},
Object {
"width": 480,
"width": 672,
},
]
}
>
<
className="row"
isFirstRow={true}
>
<
className="col"
@@ -53,6 +54,12 @@ exports[`MarkdownTable should match snapshot 1`] = `
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
</>
<
className="row"
@@ -72,6 +79,12 @@ exports[`MarkdownTable should match snapshot 1`] = `
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
</>
<
className="row"
@@ -91,6 +104,12 @@ exports[`MarkdownTable should match snapshot 1`] = `
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
</>
<
className="row"
@@ -110,6 +129,62 @@ exports[`MarkdownTable should match snapshot 1`] = `
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
</>
<
className="row"
>
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
</>
<
className="row"
>
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
</>
<
className="row"
@@ -130,6 +205,12 @@ exports[`MarkdownTable should match snapshot 1`] = `
<
className="col"
/>
<
className="col"
/>
<
className="col"
/>
</>
</View>
</ScrollView>
@@ -195,7 +276,7 @@ exports[`MarkdownTable should match snapshot 1`] = `
"paddingTop": 8,
},
Object {
"width": 480,
"width": 672,
},
]
}

View File

@@ -40,14 +40,16 @@ export default class MarkdownTable extends React.PureComponent {
this.state = {
containerWidth: 0,
contentHeight: 0,
contentWidth: 0,
maxPreviewColumns: MAX_PREVIEW_COLUMNS,
cellWidth: 0,
rowsSliced: false,
};
}
componentDidMount() {
Dimensions.addEventListener('change', this.setMaxPreviewColumns);
const window = Dimensions.get('window');
this.setMaxPreviewColumns({window});
}
componentWillUnmount() {
@@ -60,13 +62,10 @@ export default class MarkdownTable extends React.PureComponent {
}
getTableWidth = (isFullView = false) => {
let columns = this.props.numColumns;
const maxPreviewColumns = this.state.maxPreviewColumns || MAX_PREVIEW_COLUMNS;
const columns = Math.min(this.props.numColumns, maxPreviewColumns);
if (columns > MAX_PREVIEW_COLUMNS) {
columns = MAX_PREVIEW_COLUMNS;
}
return isFullView || columns === 1 ? columns * CELL_MAX_WIDTH : columns * CELL_MIN_WIDTH;
return (isFullView || columns === 1) ? columns * CELL_MAX_WIDTH : columns * CELL_MIN_WIDTH;
};
handlePress = preventDoubleTap(() => {
@@ -98,30 +97,7 @@ export default class MarkdownTable extends React.PureComponent {
};
renderPreviewRows = (isFullView = false) => {
const {maxPreviewColumns} = this.state;
const tableStyle = this.getTableStyle(isFullView);
// Add an extra prop to the last row of the table so that it knows not to render a bottom border
// since the container should be rendering that
const rows = React.Children.toArray(this.props.children).slice(0, maxPreviewColumns).map((row) => {
const children = React.Children.toArray(row.props.children).slice(0, maxPreviewColumns);
return {
...row,
props: {
...row.props,
children,
},
};
});
rows[rows.length - 1] = React.cloneElement(rows[rows.length - 1], {
isLastRow: true,
});
return (
<View style={tableStyle}>
{rows}
</View>
);
return this.renderRows(isFullView, true);
}
shouldRenderAsFlex = (isFullView = false) => {
@@ -165,12 +141,33 @@ export default class MarkdownTable extends React.PureComponent {
return tableStyle;
}
renderRows = (isFullView = false) => {
renderRows = (isFullView = false, isPreview = false) => {
const tableStyle = this.getTableStyle(isFullView);
let rows = React.Children.toArray(this.props.children);
if (isPreview) {
const {maxPreviewColumns} = this.state;
const prevRowLength = rows.length;
const prevColLength = React.Children.toArray(rows[0].props.children).length;
rows = rows.slice(0, maxPreviewColumns).map((row) => {
const children = React.Children.toArray(row.props.children).slice(0, maxPreviewColumns);
return {
...row,
props: {
...row.props,
children,
},
};
});
const rowsSliced = prevRowLength > rows.length;
const colsSliced = prevColLength > React.Children.toArray(rows[0].props.children).length;
this.setState({rowsSliced, colsSliced});
}
// Add an extra prop to the last row of the table so that it knows not to render a bottom border
// since the container should be rendering that
const rows = React.Children.toArray(this.props.children);
rows[rows.length - 1] = React.cloneElement(rows[rows.length - 1], {
isLastRow: true,
});
@@ -188,43 +185,53 @@ export default class MarkdownTable extends React.PureComponent {
}
render() {
const style = getStyleSheet(this.props.theme);
let moreRight = null;
const {containerWidth, contentHeight} = this.state;
const {theme} = this.props;
const style = getStyleSheet(theme);
const tableWidth = this.getTableWidth();
const renderAsFlex = this.shouldRenderAsFlex();
let leftOffset;
if (renderAsFlex || tableWidth > this.state.containerWidth) {
leftOffset = this.state.containerWidth - 20;
if (renderAsFlex || tableWidth > containerWidth) {
leftOffset = containerWidth - 20;
} else {
leftOffset = tableWidth - 20;
}
let expandButtonOffset = leftOffset;
if (Platform.OS === 'android') {
expandButtonOffset -= 10;
}
// Renders when table width exceeds the container, or if the columns exceed maximum allowed for previews
if ((this.state.containerWidth && tableWidth > this.state.containerWidth && !renderAsFlex) ||
// Renders when the columns were sliced, or the table width exceeds the container,
// or if the columns exceed maximum allowed for previews
let moreRight = null;
if (this.state.colsSliced ||
(containerWidth && tableWidth > containerWidth && !renderAsFlex) ||
(this.props.numColumns > MAX_PREVIEW_COLUMNS)) {
moreRight = (
<LinearGradient
colors={[
changeOpacity(this.props.theme.centerChannelColor, 0.0),
changeOpacity(this.props.theme.centerChannelColor, 0.1),
changeOpacity(theme.centerChannelColor, 0.0),
changeOpacity(theme.centerChannelColor, 0.1),
]}
start={{x: 0, y: 0}}
end={{x: 1, y: 0}}
style={[style.moreRight, {height: this.state.contentHeight, left: leftOffset}]}
style={[style.moreRight, {height: contentHeight, left: leftOffset}]}
/>
);
}
let moreBelow = null;
if (this.state.contentHeight > MAX_HEIGHT) {
if (this.state.rowsSliced) {
const width = renderAsFlex ? '100%' : Math.min(tableWidth, containerWidth);
moreBelow = (
<LinearGradient
colors={[
changeOpacity(this.props.theme.centerChannelColor, 0.0),
changeOpacity(this.props.theme.centerChannelColor, 0.1),
changeOpacity(theme.centerChannelColor, 0.0),
changeOpacity(theme.centerChannelColor, 0.1),
]}
style={[style.moreBelow, renderAsFlex ? style.fullWidth : {width: tableWidth}]}
style={[style.moreBelow, {width}]}
/>
);
}
@@ -233,7 +240,7 @@ export default class MarkdownTable extends React.PureComponent {
<TouchableWithFeedback
type={'opacity'}
onPress={this.handlePress}
style={[style.expandButton, {left: leftOffset}]}
style={[style.expandButton, {left: expandButtonOffset}]}
>
<View style={[style.iconContainer, {width: this.getTableWidth()}]}>
<View style={style.iconButton}>
@@ -314,9 +321,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
displayFlex: {
flex: 1,
},
fullWidth: {
width: '100%',
},
table: {
width: '100%',
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
@@ -326,12 +330,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
paddingRight: 10,
},
moreBelow: {
bottom: 30,
bottom: 34,
height: 20,
position: 'absolute',
left: 0,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderBottomWidth: 1,
},
moreRight: {
maxHeight: MAX_HEIGHT,

View File

@@ -32,15 +32,27 @@ describe('MarkdownTable', () => {
test('should match snapshot', () => {
const wrapper = shallowWithIntl(
<MarkdownTable {...baseProps}/>
<MarkdownTable {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should call setMaxPreviewColumns on mount', () => {
const wrapper = shallowWithIntl(
<MarkdownTable {...baseProps}/>,
);
const instance = wrapper.instance();
const setMaxPreviewColumns = jest.spyOn(instance, 'setMaxPreviewColumns');
instance.componentDidMount();
expect(setMaxPreviewColumns).toHaveBeenCalled();
expect(instance.state.maxPreviewColumns).toBeDefined();
});
test('should slice rows and columns', () => {
const wrapper = shallowWithIntl(
<MarkdownTable {...baseProps}/>
<MarkdownTable {...baseProps}/>,
);
const {maxPreviewColumns} = wrapper.state();
@@ -52,4 +64,34 @@ describe('MarkdownTable', () => {
expect(wrapper.find('.row')).toHaveLength(newMaxPreviewColumns);
expect(wrapper.find('.col')).toHaveLength(Math.pow(newMaxPreviewColumns, 2));
});
test('should add the isFirstRow prop to the first row', () => {
const wrapper = shallowWithIntl(
<MarkdownTable {...baseProps}/>,
);
const instance = wrapper.instance();
const fullRows = instance.renderRows();
const previewRows = instance.renderPreviewRows();
[fullRows, previewRows].forEach((rows) => {
const firstRows = rows.props.children.filter((child) => child.props.isFirstRow);
expect(firstRows.length).toEqual(1);
expect(firstRows[0]).toEqual(rows.props.children[0]);
});
});
test('should add the isLastRow prop to the last row', () => {
const wrapper = shallowWithIntl(
<MarkdownTable {...baseProps}/>,
);
const instance = wrapper.instance();
const fullRows = instance.renderRows();
const previewRows = instance.renderPreviewRows();
[fullRows, previewRows].forEach((rows) => {
const lastRows = rows.props.children.filter((child) => child.props.isLastRow);
expect(lastRows.length).toEqual(1);
expect(lastRows[0]).toEqual(rows.props.children[rows.props.children.length - 1]);
});
});
});

View File

@@ -32,7 +32,7 @@ export default function ActionButtonText({message, style}) {
literal={match[0]}
emojiName={match[1]}
textStyle={style}
/>
/>,
);
text = text.substring(match[0].length);
continue;
@@ -48,7 +48,7 @@ export default function ActionButtonText({message, style}) {
literal={match[0]}
emojiName={emoticonName}
textStyle={style}
/>
/>,
);
text = text.substring(match[0].length);
continue;
@@ -65,7 +65,7 @@ export default function ActionButtonText({message, style}) {
style={style}
>
{match[0]}
</Text>
</Text>,
);
text = text.substring(match[0].length);
}

View File

@@ -42,7 +42,7 @@ export default class AttachmentActions extends PureComponent {
options={action.options}
postId={postId}
disabled={action.disabled}
/>
/>,
);
break;
case 'button':
@@ -55,7 +55,7 @@ export default class AttachmentActions extends PureComponent {
name={action.name}
postId={postId}
disabled={action.disabled}
/>
/>,
);
break;
}

View File

@@ -51,7 +51,7 @@ export default class AttachmentFields extends PureComponent {
style={style.field}
>
{fieldInfos}
</View>
</View>,
);
fieldInfos = [];
rowPos = 0;
@@ -89,7 +89,7 @@ export default class AttachmentFields extends PureComponent {
onPermalinkPress={onPermalinkPress}
/>
</View>
</View>
</View>,
);
rowPos += 1;
@@ -103,7 +103,7 @@ export default class AttachmentFields extends PureComponent {
style={style.table}
>
{fieldInfos}
</View>
</View>,
);
}

View File

@@ -8,7 +8,6 @@ import {Image, View} from 'react-native';
import ProgressiveImage from 'app/components/progressive_image';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {isGifTooLarge, previewImageAtIndex, calculateDimensions} from 'app/utils/images';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const VIEWPORT_IMAGE_OFFSET = 100;
@@ -42,13 +41,13 @@ export default class AttachmentImage extends PureComponent {
}
if (imageUrl) {
ImageCacheManager.cache(null, imageUrl, this.setImageUrl);
this.setImageUrl(imageUrl);
}
}
componentDidUpdate(prevProps) {
if (this.props.imageUrl && (prevProps.imageUrl !== this.props.imageUrl)) {
ImageCacheManager.cache(null, this.props.imageUrl, this.setImageUrl);
this.setImageUrl(this.props.imageUrl);
}
}

View File

@@ -1,12 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import ImageCacheManager from 'app/utils/image_cache_manager';
import {Image} from 'react-native';
import {shallow} from 'enzyme';
import React from 'react';
const originalCacheFn = ImageCacheManager.cache;
const originalGetSizeFn = Image.getSize;
import Preferences from 'mattermost-redux/constants/preferences';
@@ -24,57 +22,35 @@ describe('AttachmentImage', () => {
afterEach(() => {
Image.getSize = originalGetSizeFn;
ImageCacheManager.cache = originalCacheFn;
});
test('it matches snapshot', () => {
const cacheFn = jest.fn((_, url, callback) => {
callback(url);
});
ImageCacheManager.cache = cacheFn;
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
});
test('it sets state based on props', () => {
const cacheFn = jest.fn((_, url, callback) => {
callback(url);
});
ImageCacheManager.cache = cacheFn;
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
const state = wrapper.state();
expect(state.hasImage).toBe(true);
expect(state.imageUri).toBe('https://images.com/image.png');
expect(state.originalWidth).toBe(32);
expect(cacheFn).toHaveBeenCalled();
});
test('it does not render image if no imageUrl is provided', () => {
const cacheFn = jest.fn((_, url, callback) => {
callback(url);
});
ImageCacheManager.cache = cacheFn;
const props = {...baseProps, imageUrl: null, imageMetadata: null};
const wrapper = shallow(<AttachmentImage {...props}/>);
const state = wrapper.state();
expect(state.hasImage).toBe(false);
expect(state.imageUri).toBe(null);
expect(cacheFn).not.toHaveBeenCalled();
});
test('it calls Image.getSize if metadata is not present', () => {
const cacheFn = jest.fn((_, url, callback) => {
callback(url);
});
const getSizeFn = jest.fn((_, callback) => {
callback(64, 64);
});
ImageCacheManager.cache = cacheFn;
Image.getSize = getSizeFn;
const props = {...baseProps, imageMetadata: null};
@@ -84,16 +60,10 @@ describe('AttachmentImage', () => {
expect(state.hasImage).toBe(true);
expect(state.imageUri).toBe('https://images.com/image.png');
expect(state.originalWidth).toBe(64);
expect(cacheFn).toHaveBeenCalled();
expect(getSizeFn).toHaveBeenCalled();
});
test('it updates image when imageUrl prop changes', () => {
const cacheFn = jest.fn((_, url, callback) => {
callback(url);
});
ImageCacheManager.cache = cacheFn;
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
wrapper.setProps({
@@ -108,15 +78,9 @@ describe('AttachmentImage', () => {
expect(state.hasImage).toBe(true);
expect(state.imageUri).toBe('https://someothersite.com/picture.png');
expect(state.originalWidth).toBe(96);
expect(cacheFn).toHaveBeenCalledTimes(2);
});
test('it does not update image when an unrelated prop changes', () => {
const cacheFn = jest.fn((_, url, callback) => {
callback(url);
});
ImageCacheManager.cache = cacheFn;
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
wrapper.setProps({
@@ -127,6 +91,5 @@ describe('AttachmentImage', () => {
expect(state.hasImage).toBe(true);
expect(state.imageUri).toBe('https://images.com/image.png');
expect(state.originalWidth).toBe(32);
expect(cacheFn).toHaveBeenCalledTimes(1);
});
});

View File

@@ -20,6 +20,7 @@ export default class AttachmentText extends PureComponent {
metadata: PropTypes.object,
onPermalinkPress: PropTypes.func,
textStyles: PropTypes.object.isRequired,
theme: PropTypes.object,
value: PropTypes.string,
};
@@ -68,8 +69,9 @@ export default class AttachmentText extends PureComponent {
hasThumbnail,
metadata,
onPermalinkPress,
value,
textStyles,
theme,
value,
} = this.props;
const {collapsed, isLongText, maxHeight} = this.state;
@@ -103,6 +105,7 @@ export default class AttachmentText extends PureComponent {
<ShowMoreButton
onPress={this.toggleCollapseState}
showMore={collapsed}
theme={theme}
/>
}
</View>

View File

@@ -55,7 +55,7 @@ export default class MessageAttachments extends PureComponent {
postId={postId}
theme={theme}
textStyles={textStyles}
/>
/>,
);
});

View File

@@ -95,6 +95,7 @@ export default class MessageAttachment extends PureComponent {
onPermalinkPress={onPermalinkPress}
textStyles={textStyles}
value={attachment.text}
theme={theme}
/>
<AttachmentFields
baseTextStyle={baseTextStyle}

View File

@@ -90,7 +90,7 @@ export default class NetworkIndicator extends PureComponent {
// Attempt to connect when this component mounts
// if the websocket is already connected it does not try and connect again
this.connect();
this.connect(true);
}
componentDidUpdate(prevProps) {
@@ -175,14 +175,14 @@ export default class NetworkIndicator extends PureComponent {
this.backgroundColor, {
toValue: 1,
duration: 100,
}
},
),
Animated.timing(
this.top, {
toValue: (this.getNavBarHeight() - HEIGHT),
duration: 300,
delay: 500,
}
},
),
]).start(() => {
this.backgroundColor.setValue(0);
@@ -317,7 +317,7 @@ export default class NetworkIndicator extends PureComponent {
}),
onPress: actions.logout,
}],
{cancelable: false}
{cancelable: false},
);
closeWebSocket(true);
});
@@ -340,7 +340,7 @@ export default class NetworkIndicator extends PureComponent {
this.top, {
toValue: this.getNavBarHeight(),
duration: 300,
}
},
).start(() => {
this.props.actions.setCurrentUserStatusOffline();
});

View File

@@ -1,18 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PasteableTextInput should render pasteable text input 1`] = `
<ConditionalWrapper
conditional={true}
wrapper={[Function]}
<TextInput
allowFontScaling={true}
onPaste={[MockFunction]}
rejectResponderTermination={true}
underlineColorAndroid="transparent"
>
<TextInput
allowFontScaling={true}
onContentSizeChange={[Function]}
onPaste={[MockFunction]}
rejectResponderTermination={true}
underlineColorAndroid="transparent"
>
My Text
</TextInput>
</ConditionalWrapper>
My Text
</TextInput>
`;

View File

@@ -10,7 +10,7 @@ describe('CustomTextInput', () => {
const onPaste = jest.fn();
const text = 'My Text';
const component = shallow(
<CustomTextInput onPaste={onPaste}>{text}</CustomTextInput>
<CustomTextInput onPaste={onPaste}>{text}</CustomTextInput>,
);
expect(component).toMatchSnapshot();
});

View File

@@ -3,10 +3,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Animated, Easing, NativeEventEmitter, NativeModules, Platform, TextInput} from 'react-native';
import {TextInput, NativeEventEmitter, NativeModules} from 'react-native';
import CustomTextInput from './custom_text_input';
import {ConditionalWrapper} from 'app/components/conditionalWrapper';
import {ViewTypes} from 'app/constants';
const {OnPasteEventManager} = NativeModules;
const OnPasteEventEmitter = new NativeEventEmitter(OnPasteEventManager);
@@ -18,10 +16,6 @@ export class PasteableTextInput extends React.PureComponent {
forwardRef: PropTypes.any,
}
state = {
inputHeight: new Animated.Value(33),
};
componentDidMount() {
this.subscription = OnPasteEventEmitter.addListener('onPaste', this.onPaste);
}
@@ -37,41 +31,14 @@ export class PasteableTextInput extends React.PureComponent {
return onPaste?.(null, event);
}
animateHeight = (event) => {
if (Platform.OS === 'ios') {
const {height} = event.nativeEvent.contentSize;
const {style} = this.props;
const {inputHeight} = this.state;
const newHeight = Math.min(style.maxHeight, height + ViewTypes.INPUT_VERTICAL_PADDING);
const transitionSpeed = height === ViewTypes.INPUT_LINE_HEIGHT ? 500 : 1;
Animated.timing(inputHeight, {
toValue: newHeight,
duration: transitionSpeed,
easing: Easing.inOut(Easing.sin),
}).start();
}
}
wrapperLayout = (children) => {
const {inputHeight} = this.state;
return <Animated.View style={{flex: 1, height: inputHeight}}>{children}</Animated.View>;
}
render() {
const {forwardRef, ...props} = this.props;
return (
<ConditionalWrapper
conditional={Platform.OS === 'ios'}
wrapper={this.wrapperLayout}
>
<CustomTextInput
{...props}
ref={forwardRef}
onContentSizeChange={this.animateHeight}
/>
</ConditionalWrapper>
<CustomTextInput
{...props}
ref={forwardRef}
/>
);
}
}

View File

@@ -13,7 +13,7 @@ describe('PasteableTextInput', () => {
const onPaste = jest.fn();
const text = 'My Text';
const component = shallow(
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>,
);
expect(component).toMatchSnapshot();
});
@@ -23,7 +23,7 @@ describe('PasteableTextInput', () => {
const event = {someData: 'data'};
const text = 'My Text';
shallow(
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>,
);
nativeEventEmitter.emit('onPaste', event);
expect(onPaste).toHaveBeenCalledWith(null, event);
@@ -34,7 +34,7 @@ describe('PasteableTextInput', () => {
const onPaste = jest.fn();
const text = 'My Text';
const component = shallow(
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>,
);
component.instance().subscription.remove = mockRemove;

View File

@@ -320,7 +320,7 @@ export default class Post extends PureComponent {
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
cancelTouchOnPanning={true}
>
<React.Fragment>
<>
<PostPreHeader
isConsecutive={mergeMessage}
isFlagged={isFlagged}
@@ -355,7 +355,7 @@ export default class Post extends PureComponent {
/>
</View>
</View>
</React.Fragment>
</>
</TouchableWithFeedback>
</View>
);

View File

@@ -71,7 +71,7 @@ export default class PostAddChannelMember extends React.PureComponent {
{
username: currentUser.username,
addedUsername: usernames[index],
}
},
);
actions.sendAddToChannelEphemeralPost(currentUser, usernames[index], message, post.channel_id, post.root_id);

View File

@@ -259,7 +259,7 @@ exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage
"marginTop": 5,
},
Object {
"height": 112.56666666666666,
"height": 69.83261802575107,
"width": 307,
},
]
@@ -277,7 +277,7 @@ exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage
"borderRadius": 3,
},
Object {
"height": 112.56666666666666,
"height": 69.83261802575107,
"width": 307,
},
]

View File

@@ -14,7 +14,6 @@ import {TABLET_WIDTH} from 'app/components/sidebars/drawer_layout';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {DeviceTypes} from 'app/constants';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {previewImageAtIndex, calculateDimensions} from 'app/utils/images';
import {getNearestPoint} from 'app/utils/opengraph';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
@@ -47,6 +46,10 @@ export default class PostAttachmentOpenGraph extends PureComponent {
this.mounted = true;
this.fetchData(this.props.link, this.props.openGraphData);
if (this.state.openGraphImageUrl) {
this.getImageSize(this.state.openGraphImageUrl);
}
}
componentWillReceiveProps(nextProps) {
@@ -56,7 +59,9 @@ export default class PostAttachmentOpenGraph extends PureComponent {
}
if (this.props.openGraphData !== nextProps.openGraphData) {
this.setState(this.getBestImageUrl(nextProps.openGraphData));
this.setState(this.getBestImageUrl(nextProps.openGraphData), () => {
this.getImageSize(this.state.openGraphImageUrl);
});
}
}
@@ -110,10 +115,6 @@ export default class PostAttachmentOpenGraph extends PureComponent {
dimensions = calculateDimensions(ogImage.height, ogImage.width, this.getViewPostWidth());
}
if (imageUrl) {
ImageCacheManager.cache(this.getFilename(imageUrl), imageUrl, this.getImageSize);
}
return {
hasImage: true,
...dimensions,
@@ -143,7 +144,7 @@ export default class PostAttachmentOpenGraph extends PureComponent {
}
if (!ogImage) {
ogImage = openGraphData.images.find((i) => i.url === openGraphImageUrl || i.secure_url === openGraphImageUrl);
ogImage = openGraphData?.images?.find((i) => i.url === openGraphImageUrl || i.secure_url === openGraphImageUrl);
}
// Fallback when the ogImage does not have dimensions but there is a metaImage defined

View File

@@ -38,7 +38,7 @@ describe('PostAttachmentOpenGraph', () => {
test('should match snapshot, without image and description', () => {
const wrapper = shallow(
<PostAttachmentOpenGraph {...baseProps}/>
<PostAttachmentOpenGraph {...baseProps}/>,
);
// should return null
@@ -58,7 +58,7 @@ describe('PostAttachmentOpenGraph', () => {
<PostAttachmentOpenGraph
{...baseProps}
openGraphData={newOpenGraphData}
/>
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
@@ -68,14 +68,14 @@ describe('PostAttachmentOpenGraph', () => {
<PostAttachmentOpenGraph
{...baseProps}
openGraphData={{}}
/>
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match state and snapshot, on renderImage', () => {
const wrapper = shallow(
<PostAttachmentOpenGraph {...baseProps}/>
<PostAttachmentOpenGraph {...baseProps}/>,
);
// should return null
@@ -99,7 +99,7 @@ describe('PostAttachmentOpenGraph', () => {
<PostAttachmentOpenGraph
{...baseProps}
openGraphData={openGraphData}
/>
/>,
);
// should return null
@@ -112,7 +112,7 @@ describe('PostAttachmentOpenGraph', () => {
test('should match result on getFilename', () => {
const wrapper = shallow(
<PostAttachmentOpenGraph {...baseProps}/>
<PostAttachmentOpenGraph {...baseProps}/>,
);
const testCases = [

View File

@@ -431,6 +431,7 @@ export default class PostBody extends PureComponent {
<ShowMoreButton
highlight={highlight}
onPress={this.openLongPost}
theme={theme}
/>
}
{this.renderPostAdditionalContent(blockStyles, messageStyle, textStyles)}

View File

@@ -92,7 +92,6 @@ describe('PostBody', () => {
event.nativeEvent.layout.height = wrapper.state('maxHeight') - 1;
instance.measurePost(event);
expect(wrapper.state('isLongPost')).toEqual(false);
event.nativeEvent.layout.height = wrapper.state('maxHeight') + 1;
instance.measurePost(event);
expect(wrapper.state('isLongPost')).toEqual(true);

View File

@@ -23,7 +23,6 @@ import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {DeviceTypes} from 'app/constants';
import CustomPropTypes from 'app/constants/custom_prop_types';
import ImageCacheManager from 'app/utils/image_cache_manager';
import {previewImageAtIndex, calculateDimensions} from 'app/utils/images';
import {getYouTubeVideoId, isImageLink, isYoutubeLink} from 'app/utils/url';
@@ -126,7 +125,6 @@ export default class PostBodyAdditionalContent extends PureComponent {
} else if (isYoutubeLink(link)) {
const videoId = getYouTubeVideoId(link);
imageUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
ImageCacheManager.cache(null, `https://i.ytimg.com/vi/${videoId}/default.jpg`, () => true);
} else {
const {data} = await this.props.actions.getRedirectLocation(link);
@@ -141,7 +139,6 @@ export default class PostBodyAdditionalContent extends PureComponent {
} else if (isYoutubeLink(shortenedLink)) {
const videoId = getYouTubeVideoId(shortenedLink);
imageUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
ImageCacheManager.cache(null, `https://i.ytimg.com/vi/${videoId}/default.jpg`, () => true);
}
if (this.mounted) {
this.setState({shortenedLink});
@@ -150,7 +147,7 @@ export default class PostBodyAdditionalContent extends PureComponent {
}
if (imageUrl) {
ImageCacheManager.cache(null, imageUrl, this.getImageSize);
this.getImageSize(imageUrl);
}
}
};

View File

@@ -51,7 +51,7 @@ exports[`PostHeader should match snapshot when just a base post 1`] = `
John Smith
</Text>
</TouchableWithFeedbackIOS>
<InjectIntl(FormattedTime)
<FormattedTime
hour12={true}
style={
Object {
@@ -123,7 +123,7 @@ exports[`PostHeader should match snapshot when just a base post in landscape mod
John Smith
</Text>
</TouchableWithFeedbackIOS>
<InjectIntl(FormattedTime)
<FormattedTime
hour12={true}
style={
Object {
@@ -232,7 +232,7 @@ exports[`PostHeader should match snapshot when post is autoresponder 1`] = `
}
}
/>
<InjectIntl(FormattedTime)
<FormattedTime
hour12={true}
style={
Object {
@@ -297,7 +297,7 @@ exports[`PostHeader should match snapshot when post is from system message 1`] =
}
/>
</View>
<InjectIntl(FormattedTime)
<FormattedTime
hour12={true}
style={
Object {
@@ -367,7 +367,7 @@ exports[`PostHeader should match snapshot when post is same thread, so dont disp
John Smith
</Text>
</TouchableWithFeedbackIOS>
<InjectIntl(FormattedTime)
<FormattedTime
hour12={true}
style={
Object {
@@ -494,7 +494,7 @@ exports[`PostHeader should match snapshot when post isBot and shouldRenderReplyB
}
}
/>
<InjectIntl(FormattedTime)
<FormattedTime
hour12={true}
style={
Object {
@@ -646,7 +646,7 @@ exports[`PostHeader should match snapshot when post isBot and shouldRenderReplyB
}
}
/>
<InjectIntl(FormattedTime)
<FormattedTime
hour12={true}
style={
Object {
@@ -758,7 +758,7 @@ exports[`PostHeader should match snapshot when post renders Commented On for new
John Smith
</Text>
</TouchableWithFeedbackIOS>
<InjectIntl(FormattedTime)
<FormattedTime
hour12={true}
style={
Object {
@@ -845,7 +845,7 @@ exports[`PostHeader should match snapshot when post should display reply button
John Smith
</Text>
</TouchableWithFeedbackIOS>
<InjectIntl(FormattedTime)
<FormattedTime
hour12={true}
style={
Object {

View File

@@ -41,7 +41,7 @@ describe('PostHeader', () => {
test('should match snapshot when just a base post', () => {
const wrapper = shallow(
<PostHeader {...baseProps}/>
<PostHeader {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find('#ReplyIcon').exists()).toEqual(false);
@@ -55,7 +55,7 @@ describe('PostHeader', () => {
};
const wrapper = shallow(
<PostHeader {...props}/>
<PostHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
@@ -67,7 +67,7 @@ describe('PostHeader', () => {
};
const wrapper = shallow(
<PostHeader {...props}/>
<PostHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
@@ -79,7 +79,7 @@ describe('PostHeader', () => {
};
const wrapper = shallow(
<PostHeader {...props}/>
<PostHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find('#ReplyIcon').exists()).toEqual(false);
@@ -92,7 +92,7 @@ describe('PostHeader', () => {
};
const wrapper = shallow(
<PostHeader {...props}/>
<PostHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find('#ReplyIcon').exists()).toEqual(false);
@@ -108,7 +108,7 @@ describe('PostHeader', () => {
};
const wrapper = shallow(
<PostHeader {...props}/>
<PostHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
@@ -123,7 +123,7 @@ describe('PostHeader', () => {
};
const wrapper = shallow(
<PostHeader {...props}/>
<PostHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
@@ -135,7 +135,7 @@ describe('PostHeader', () => {
};
const wrapper = shallow(
<PostHeader {...props}/>
<PostHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find('#ReplyIcon').exists()).toEqual(false);
@@ -150,7 +150,7 @@ describe('PostHeader', () => {
};
const wrapper = shallow(
<PostHeader {...props}/>
<PostHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});

View File

@@ -21,7 +21,7 @@ describe('PostPreHeader', () => {
test('should match snapshot when not flagged or pinned post', () => {
const wrapper = shallow(
<PostPreHeader {...baseProps}/>
<PostPreHeader {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.type()).toBeNull();
@@ -35,7 +35,7 @@ describe('PostPreHeader', () => {
};
const wrapper = shallow(
<PostPreHeader {...props}/>
<PostPreHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.type()).toBeNull();
@@ -49,7 +49,7 @@ describe('PostPreHeader', () => {
};
const wrapper = shallow(
<PostPreHeader {...props}/>
<PostPreHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.type()).toBeNull();
@@ -62,7 +62,7 @@ describe('PostPreHeader', () => {
};
const wrapper = shallow(
<PostPreHeader {...props}/>
<PostPreHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find('#flagIcon').exists()).toEqual(true);
@@ -77,7 +77,7 @@ describe('PostPreHeader', () => {
};
const wrapper = shallow(
<PostPreHeader {...props}/>
<PostPreHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find('#flagIcon').exists()).toEqual(false);
@@ -93,7 +93,7 @@ describe('PostPreHeader', () => {
};
const wrapper = shallow(
<PostPreHeader {...props}/>
<PostPreHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find('#flagIcon').exists()).toEqual(true);
@@ -110,7 +110,7 @@ describe('PostPreHeader', () => {
};
const wrapper = shallow(
<PostPreHeader {...props}/>
<PostPreHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find('#flagIcon').exists()).toEqual(true);
@@ -127,7 +127,7 @@ describe('PostPreHeader', () => {
};
const wrapper = shallow(
<PostPreHeader {...props}/>
<PostPreHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find('#flagIcon').exists()).toEqual(false);
@@ -145,7 +145,7 @@ describe('PostPreHeader', () => {
};
const wrapper = shallow(
<PostPreHeader {...props}/>
<PostPreHeader {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.type()).toBeNull();

View File

@@ -20,10 +20,11 @@ exports[`PostList setting channel deep link 1`] = `
"channel-id",
undefined,
undefined,
undefined,
]
}
horizontal={false}
initialNumToRender={15}
initialNumToRender={7}
inverted={true}
keyExtractor={[Function]}
keyboardDismissMode="interactive"
@@ -34,12 +35,13 @@ exports[`PostList setting channel deep link 1`] = `
"minIndexForVisible": 0,
}
}
maxToRenderPerBatch={16}
maxToRenderPerBatch={10}
numColumns={1}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollToIndexFailed={[Function]}
refreshControl={
<RefreshControl
@@ -56,8 +58,13 @@ exports[`PostList setting channel deep link 1`] = `
removeClippedSubviews={true}
renderItem={[Function]}
scrollEventThrottle={60}
style={
Object {
"flex": 1,
}
}
updateCellsBatchingPeriod={50}
windowSize={21}
windowSize={50}
/>
`;
@@ -81,10 +88,11 @@ exports[`PostList setting permalink deep link 1`] = `
"channel-id",
undefined,
undefined,
undefined,
]
}
horizontal={false}
initialNumToRender={15}
initialNumToRender={7}
inverted={true}
keyExtractor={[Function]}
keyboardDismissMode="interactive"
@@ -95,12 +103,13 @@ exports[`PostList setting permalink deep link 1`] = `
"minIndexForVisible": 0,
}
}
maxToRenderPerBatch={16}
maxToRenderPerBatch={10}
numColumns={1}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollToIndexFailed={[Function]}
refreshControl={
<RefreshControl
@@ -117,8 +126,13 @@ exports[`PostList setting permalink deep link 1`] = `
removeClippedSubviews={true}
renderItem={[Function]}
scrollEventThrottle={60}
style={
Object {
"flex": 1,
}
}
updateCellsBatchingPeriod={50}
windowSize={21}
windowSize={50}
/>
`;
@@ -142,10 +156,11 @@ exports[`PostList should match snapshot 1`] = `
"channel-id",
undefined,
undefined,
undefined,
]
}
horizontal={false}
initialNumToRender={15}
initialNumToRender={7}
inverted={true}
keyExtractor={[Function]}
keyboardDismissMode="interactive"
@@ -156,12 +171,13 @@ exports[`PostList should match snapshot 1`] = `
"minIndexForVisible": 0,
}
}
maxToRenderPerBatch={16}
maxToRenderPerBatch={10}
numColumns={1}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollToIndexFailed={[Function]}
refreshControl={
<RefreshControl
@@ -178,7 +194,12 @@ exports[`PostList should match snapshot 1`] = `
removeClippedSubviews={true}
renderItem={[Function]}
scrollEventThrottle={60}
style={
Object {
"flex": 1,
}
}
updateCellsBatchingPeriod={50}
windowSize={21}
windowSize={50}
/>
`;

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {memo} from 'react';
import PropTypes from 'prop-types';
import {
View,
@@ -71,4 +71,4 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
};
});
export default NewMessagesDivider;
export default memo(NewMessagesDivider);

View File

@@ -18,11 +18,14 @@ import {changeOpacity} from 'app/utils/theme';
import {matchDeepLink} from 'app/utils/url';
import telemetry from 'app/telemetry';
import {showModalOverCurrentContext} from 'app/actions/navigation';
import {alertErrorWithFallback} from 'app/utils/general';
import {t} from 'app/utils/i18n';
import DateHeader from './date_header';
import NewMessagesDivider from './new_messages_divider';
const INITIAL_BATCH_TO_RENDER = 15;
const INITIAL_BATCH_TO_RENDER = 7;
const LOADING_POSTS_HEIGHT = 53;
const SCROLL_UP_MULTIPLIER = 3.5;
const SCROLL_POSITION_CONFIG = {
@@ -53,6 +56,7 @@ export default class PostList extends PureComponent {
isSearchResult: PropTypes.bool,
lastPostIndex: PropTypes.number.isRequired,
lastViewedAt: PropTypes.number, // Used by container // eslint-disable-line no-unused-prop-types
loadMorePostsVisible: PropTypes.bool,
onLoadMoreUp: PropTypes.func,
onHashtagPress: PropTypes.func,
onPermalinkPress: PropTypes.func,
@@ -89,6 +93,7 @@ export default class PostList extends PureComponent {
this.hasDoneInitialScroll = false;
this.contentOffsetY = 0;
this.shouldScrollToBottom = false;
this.cancelScrollToIndex = false;
this.makeExtraData = makeExtraData();
this.flatListRef = React.createRef();
@@ -127,7 +132,7 @@ export default class PostList extends PureComponent {
this.shouldScrollToBottom = false;
}
if (!this.hasDoneInitialScroll && this.props.initialIndex > 0 && this.state.contentHeight) {
if (!this.hasDoneInitialScroll && this.props.initialIndex > 0 && this.state.contentHeight > LOADING_POSTS_HEIGHT) {
this.scrollToInitialIndexIfNeeded(this.props.initialIndex);
}
@@ -136,7 +141,7 @@ export default class PostList extends PureComponent {
this.props.postIds.length &&
this.state.contentHeight &&
this.state.contentHeight < this.state.postListHeight &&
this.props.extraData
!this.props.extraData
) {
this.loadToFillContent();
}
@@ -145,13 +150,20 @@ export default class PostList extends PureComponent {
componentWillUnmount() {
EventEmitter.off('scroll-to-bottom', this.handleSetScrollToBottom);
if (this.animationFrameIndexFailed) {
cancelAnimationFrame(this.animationFrameIndexFailed);
}
this.resetPostList();
}
if (this.animationFrameInitialIndex) {
cancelAnimationFrame(this.animationFrameInitialIndex);
}
flatListScrollToIndex = (index) => {
this.animationFrameInitialIndex = requestAnimationFrame(() => {
if (!this.cancelScrollToIndex) {
this.flatListRef.current.scrollToIndex({
animated: false,
index,
viewOffset: 0,
viewPosition: 1, // 0 is at bottom
});
}
});
}
getItemCount = () => {
@@ -165,12 +177,14 @@ export default class PostList extends PureComponent {
};
handleContentSizeChange = (contentWidth, contentHeight) => {
this.setState({contentHeight}, () => {
if (this.state.postListHeight && contentHeight < this.state.postListHeight && this.props.extraData) {
// We still have less than 1 screen of posts loaded with more to get, so load more
this.props.onLoadMoreUp();
}
});
if (this.state.contentHeight !== contentHeight) {
this.setState({contentHeight}, () => {
if (this.state.postListHeight && contentHeight < this.state.postListHeight && !this.props.extraData && contentHeight > LOADING_POSTS_HEIGHT) {
// We still have less than 1 screen of posts loaded with more to get, so load more
this.props.onLoadMoreUp();
}
});
}
};
handleDeepLink = (url) => {
@@ -180,7 +194,7 @@ export default class PostList extends PureComponent {
if (match) {
if (match.type === DeepLinkTypes.CHANNEL) {
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName);
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, this.errorBadChannel);
} else if (match.type === DeepLinkTypes.PERMALINK) {
this.handlePermalinkPress(match.postId, match.teamName);
}
@@ -201,7 +215,29 @@ export default class PostList extends PureComponent {
handleLayout = (event) => {
const {height} = event.nativeEvent.layout;
this.setState({postListHeight: height});
if (this.state.postListHeight !== height) {
this.setState({postListHeight: height});
}
};
errorBadTeam = () => {
const {intl} = this.context;
const message = {
id: t('mobile.server_link.unreachable_team.error'),
defaultMessage: 'This link belongs to a deleted team or to a team to which you do not have access.',
};
alertErrorWithFallback(intl, {}, message);
};
errorBadChannel = () => {
const {intl} = this.context;
const message = {
id: t('mobile.server_link.unreachable_channel.error'),
defaultMessage: 'This link belongs to a deleted channel or to a channel to which you do not have access.',
};
alertErrorWithFallback(intl, {}, message);
};
handlePermalinkPress = (postId, teamName) => {
@@ -211,7 +247,7 @@ export default class PostList extends PureComponent {
if (onPermalinkPress) {
onPermalinkPress(postId, true);
} else {
actions.loadChannelsByTeamName(teamName);
actions.loadChannelsByTeamName(teamName, this.errorBadTeam);
this.showPermalinkView(postId);
}
};
@@ -250,6 +286,10 @@ export default class PostList extends PureComponent {
}
};
handleScrollBeginDrag = () => {
this.cancelScrollToIndex = true;
}
handleScrollToIndexFailed = () => {
this.animationFrameIndexFailed = requestAnimationFrame(() => {
if (this.props.initialIndex > 0 && this.state.contentHeight > 0) {
@@ -269,7 +309,7 @@ export default class PostList extends PureComponent {
};
loadToFillContent = () => {
setTimeout(() => {
this.fillContentTimer = setTimeout(() => {
this.handleContentSizeChange(0, this.state.contentHeight);
});
};
@@ -358,26 +398,41 @@ export default class PostList extends PureComponent {
};
scrollToBottom = () => {
setTimeout(() => {
this.scrollToBottomTimer = setTimeout(() => {
if (this.flatListRef.current) {
this.flatListRef.current.scrollToOffset({offset: 0, animated: true});
}
}, 250);
};
flatListScrollToIndex = (index) => {
this.flatListRef.current.scrollToIndex({
animated: false,
index,
viewOffset: 0,
viewPosition: 1, // 0 is at bottom
});
}
resetPostList = () => {
this.contentOffsetY = 0;
this.hasDoneInitialScroll = false;
this.setState({contentHeight: 0});
this.cancelScrollToIndex = false;
if (this.animationFrameIndexFailed) {
cancelAnimationFrame(this.animationFrameIndexFailed);
}
if (this.animationFrameInitialIndex) {
cancelAnimationFrame(this.animationFrameInitialIndex);
}
if (this.fillContentTimer) {
clearTimeout(this.fillContentTimer);
}
if (this.scrollToBottomTimer) {
clearTimeout(this.scrollToBottomTimer);
}
if (this.scrollToInitialTimer) {
clearTimeout(this.scrollToInitialTimer);
}
if (this.state.contentHeight !== 0) {
this.setState({contentHeight: 0});
}
}
scrollToIndex = (index) => {
@@ -394,14 +449,14 @@ export default class PostList extends PureComponent {
this.hasDoneInitialScroll = true;
this.scrollToIndex(index);
} else if (count < 3) {
setTimeout(() => {
this.scrollToInitialTimer = setTimeout(() => {
this.scrollToInitialIndexIfNeeded(index, count + 1);
}, 300);
}
}
};
showPermalinkView = (postId) => {
showPermalinkView = (postId, error = '') => {
const {actions} = this.props;
actions.selectFocusedPostId(postId);
@@ -411,6 +466,7 @@ export default class PostList extends PureComponent {
const passProps = {
isPermalink: true,
onClose: this.handleClosePermalink,
error,
};
const options = {
layout: {
@@ -426,7 +482,9 @@ export default class PostList extends PureComponent {
render() {
const {
channelId,
extraData,
highlightPostId,
loadMorePostsVisible,
postIds,
refreshing,
scrollViewNativeID,
@@ -447,9 +505,10 @@ export default class PostList extends PureComponent {
<FlatList
key={`recyclerFor-${channelId}-${hasPostsKey}`}
ref={this.flatListRef}
style={{flex: 1}}
contentContainerStyle={styles.postListContent}
data={postIds}
extraData={this.makeExtraData(channelId, highlightPostId, this.props.extraData)}
extraData={this.makeExtraData(channelId, highlightPostId, extraData, loadMorePostsVisible)}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
inverted={true}
keyboardDismissMode={'interactive'}
@@ -457,16 +516,17 @@ export default class PostList extends PureComponent {
keyExtractor={this.keyExtractor}
ListFooterComponent={this.props.renderFooter}
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
onContentSizeChange={this.handleContentSizeChange}
onLayout={this.handleLayout}
onScroll={this.handleScroll}
onScrollBeginDrag={this.handleScrollBeginDrag}
onScrollToIndexFailed={this.handleScrollToIndexFailed}
removeClippedSubviews={true}
renderItem={this.renderItem}
scrollEventThrottle={60}
refreshControl={refreshControl}
nativeID={scrollViewNativeID}
windowSize={50}
/>
);
}

View File

@@ -14,7 +14,7 @@ jest.mock('react-intl');
describe('PostList', () => {
const serverURL = 'https://server-url.fake';
const deeplinkRoot = 'mattermost-beta://server-url.fake';
const deeplinkRoot = 'mattermost://server-url.fake';
const baseProps = {
actions: {
@@ -40,7 +40,7 @@ describe('PostList', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<PostList {...baseProps}/>
<PostList {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
@@ -49,7 +49,7 @@ describe('PostList', () => {
test('setting permalink deep link', () => {
const showModalOverCurrentContext = jest.spyOn(NavigationActions, 'showModalOverCurrentContext');
const wrapper = shallow(
<PostList {...baseProps}/>
<PostList {...baseProps}/>,
);
wrapper.setProps({deepLinkURL: deepLinks.permalink});
@@ -61,7 +61,7 @@ describe('PostList', () => {
test('setting channel deep link', () => {
const wrapper = shallow(
<PostList {...baseProps}/>
<PostList {...baseProps}/>,
);
wrapper.setProps({deepLinkURL: deepLinks.channel});
@@ -74,7 +74,7 @@ describe('PostList', () => {
jest.spyOn(global, 'requestAnimationFrame').mockImplementation((cb) => cb());
const wrapper = shallow(
<PostList {...baseProps}/>
<PostList {...baseProps}/>,
);
const instance = wrapper.instance();
const flatListScrollToIndex = jest.spyOn(instance, 'flatListScrollToIndex');
@@ -103,13 +103,13 @@ describe('PostList', () => {
test('should load more posts if available space on the screen', () => {
const wrapper = shallow(
<PostList {...baseProps}/>
<PostList {...baseProps}/>,
);
const instance = wrapper.instance();
instance.loadToFillContent = jest.fn();
wrapper.setProps({
extraData: true,
extraData: false,
});
expect(instance.loadToFillContent).toHaveBeenCalledTimes(0);
@@ -120,7 +120,7 @@ describe('PostList', () => {
expect(instance.loadToFillContent).toHaveBeenCalledTimes(1);
wrapper.setProps({
extraData: false,
extraData: true,
});
expect(instance.loadToFillContent).toHaveBeenCalledTimes(1);

View File

@@ -14,7 +14,7 @@ exports[`PostTextBox should match, full snapshot 1`] = `
"borderTopWidth": 1,
"flexDirection": "row",
"justifyContent": "center",
"paddingVertical": 4,
"paddingBottom": 2,
},
null,
]
@@ -24,6 +24,7 @@ exports[`PostTextBox should match, full snapshot 1`] = `
contentContainerStyle={
Object {
"alignItems": "stretch",
"paddingTop": 7,
}
}
disableScrollViewPanResponder={true}
@@ -36,11 +37,8 @@ exports[`PostTextBox should match, full snapshot 1`] = `
style={
Array [
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"flexDirection": "column",
"marginLeft": 10,
"marginRight": 10,
},
]
}
@@ -53,6 +51,7 @@ exports[`PostTextBox should match, full snapshot 1`] = `
keyboardType="default"
multiline={true}
onChangeText={[Function]}
onContentSizeChange={null}
onEndEditing={[Function]}
onPaste={[Function]}
onSelectionChange={[Function]}
@@ -61,100 +60,250 @@ exports[`PostTextBox should match, full snapshot 1`] = `
style={
Object {
"color": "#3d3c40",
"fontSize": 14,
"fontSize": 15,
"lineHeight": 20,
"maxHeight": 150,
"paddingBottom": 8,
"paddingLeft": 12,
"paddingRight": 12,
"paddingTop": 8,
"minHeight": 30,
"paddingBottom": 6,
"paddingHorizontal": 12,
"paddingTop": 6,
}
}
underlineColorAndroid="transparent"
value=""
/>
<Connect(FileUploadPreview)
files={Array []}
rootId=""
/>
<View
style={
Object {
"alignItems": "center",
"display": "flex",
"flexDirection": "row",
"justifyContent": "space-between",
}
}
>
<React.Fragment>
<Connect(FileUploadPreview)
files={Array []}
rootId=""
/>
<View
style={
Object {
"alignItems": "center",
"display": "flex",
"flexDirection": "row",
"justifyContent": "space-between",
"paddingBottom": 1,
}
}
>
<TouchableOpacity
activeOpacity={0.2}
disabled={false}
onPress={[Function]}
<View
style={
Object {
"paddingLeft": 10,
"paddingRight": 10,
"display": "flex",
"flexDirection": "row",
"height": 44,
}
}
>
<Icon
allowFontScaling={false}
color="#3d3c40"
name="at"
size={20}
/>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.2}
disabled={false}
onPress={[Function]}
style={
Object {
"paddingLeft": 10,
"paddingRight": 10,
}
}
>
<Image
source={
<TouchableOpacity
activeOpacity={0.2}
disabled={false}
onPress={[Function]}
style={
Object {
"testUri": "../../../dist/assets/images/icons/slash-forward-box.png",
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
>
<Icon
allowFontScaling={false}
color="rgba(61,60,64,0.64)"
name="at"
size={24}
/>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.2}
disabled={false}
onPress={[Function]}
style={
Array [
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
>
<Image
source={
Object {
"height": 20,
"opacity": 1,
"tintColor": "#3d3c40",
"width": 20,
},
]
"testUri": "../../../dist/assets/images/icons/slash-forward-box.png",
}
}
style={
Array [
Object {
"height": 24,
"opacity": 1,
"tintColor": "rgba(61,60,64,0.64)",
"width": 24,
},
]
}
/>
</TouchableOpacity>
<FileUploadButton
blurTextBox={[Function]}
browseFileTypes="public.item"
buttonContainerStyle={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
canBrowseFiles={true}
canBrowsePhotoLibrary={true}
canBrowseVideoLibrary={true}
canTakePhoto={true}
canTakeVideo={true}
extraOptions={null}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
</TouchableOpacity>
<FileUploadButton
blurTextBox={[Function]}
browseFileTypes="public.item"
canBrowseFiles={true}
canBrowsePhotoLibrary={true}
canBrowseVideoLibrary={true}
canTakePhoto={true}
canTakeVideo={true}
extraOptions={null}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
<ImageUploadButton
blurTextBox={[Function]}
browseFileTypes="public.item"
buttonContainerStyle={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
canBrowseFiles={true}
canBrowsePhotoLibrary={true}
canBrowseVideoLibrary={true}
canTakePhoto={true}
canTakeVideo={true}
extraOptions={null}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
<AttachmentButton
blurTextBox={[Function]}
buttonContainerStyle={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 10,
}
}
canTakePhoto={true}
canTakeVideo={true}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
</View>
<SendButton
disabled={true}
handleSendMessage={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
@@ -184,131 +333,9 @@ exports[`PostTextBox should match, full snapshot 1`] = `
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
<ImageUploadButton
blurTextBox={[Function]}
browseFileTypes="public.item"
canBrowseFiles={true}
canBrowsePhotoLibrary={true}
canBrowseVideoLibrary={true}
canTakePhoto={true}
canTakeVideo={true}
extraOptions={null}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
<AttachmentButton
blurTextBox={[Function]}
canTakePhoto={true}
canTakeVideo={true}
fileCount={0}
maxFileCount={5}
maxFileSize={1024}
onShowFileMaxWarning={[Function]}
onShowFileSizeWarning={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
validMimeTypes={Array []}
/>
</View>
<SendButton
disabled={true}
handleSendMessage={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</View>
</React.Fragment>
</ScrollView>
</View>
</React.Fragment>

View File

@@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CameraButton should match snapshot 1`] = `
<TouchableWithFeedbackIOS
onPress={[Function]}
style={Object {}}
type="opacity"
>
<Icon
allowFontScaling={false}
color="rgba(61,60,64,0.64)"
name="camera-outline"
size={24}
/>
</TouchableWithFeedbackIOS>
`;

View File

@@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FileUploadButton should match snapshot 1`] = `
<TouchableWithFeedbackIOS
onPress={[Function]}
style={Object {}}
type="opacity"
>
<Icon
allowFontScaling={false}
color="rgba(61,60,64,0.64)"
name="file-document-outline"
size={24}
/>
</TouchableWithFeedbackIOS>
`;

View File

@@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ImageUploadButton should match snapshot 1`] = `
<TouchableWithFeedbackIOS
onPress={[Function]}
style={Object {}}
type="opacity"
>
<Icon
allowFontScaling={false}
color="rgba(61,60,64,0.64)"
name="image-outline"
size={24}
/>
</TouchableWithFeedbackIOS>
`;

View File

@@ -6,31 +6,26 @@ import {intlShape} from 'react-intl';
import {
Alert,
Platform,
StyleSheet,
} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import DeviceInfo from 'react-native-device-info';
import {ICON_SIZE} from 'app/constants/post_textbox';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import ImagePicker from 'react-native-image-picker';
import Permissions from 'react-native-permissions';
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
import {changeOpacity} from 'app/utils/theme';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {PermissionTypes} from 'app/constants';
export default class AttachmentButton extends PureComponent {
static propTypes = {
validMimeTypes: PropTypes.array,
fileCount: PropTypes.number,
maxFileCount: PropTypes.number.isRequired,
maxFileSize: PropTypes.number.isRequired,
onShowFileMaxWarning: PropTypes.func,
onShowFileSizeWarning: PropTypes.func,
onShowUnsupportedMimeTypeWarning: PropTypes.func,
theme: PropTypes.object.isRequired,
uploadFiles: PropTypes.func.isRequired,
buttonContainerStyle: PropTypes.object,
};
static defaultProps = {
@@ -102,106 +97,70 @@ export default class AttachmentButton extends PureComponent {
return;
}
this.uploadFiles([response]);
this.props.uploadFiles([response]);
});
}
};
hasCameraPermission = async () => {
if (Platform.OS === 'ios') {
const {formatMessage} = this.context.intl;
let permissionRequest;
const targetSource = 'camera';
const hasPermissionToStorage = await Permissions.check(targetSource);
const {formatMessage} = this.context.intl;
const targetSource = Platform.OS === 'ios' ?
Permissions.PERMISSIONS.IOS.CAMERA :
Permissions.PERMISSIONS.ANDROID.CAMERA;
const hasPermission = await Permissions.check(targetSource);
switch (hasPermissionToStorage) {
case PermissionTypes.UNDETERMINED:
permissionRequest = await Permissions.request(targetSource);
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
return false;
}
break;
case PermissionTypes.DENIED: {
const canOpenSettings = await Permissions.canOpenSettings();
let grantOption = null;
if (canOpenSettings) {
grantOption = {
switch (hasPermission) {
case Permissions.RESULTS.DENIED:
case Permissions.RESULTS.UNAVAILABLE: {
const permissionRequest = await Permissions.request(targetSource);
return permissionRequest === Permissions.RESULTS.GRANTED;
}
case Permissions.RESULTS.BLOCKED: {
const grantOption = {
text: formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
}),
onPress: () => Permissions.openSettings(),
};
const {title, text} = this.getPermissionDeniedMessage();
Alert.alert(
title,
text,
[
grantOption,
{
text: formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
id: 'mobile.permission_denied_dismiss',
defaultMessage: 'Don\'t Allow',
}),
onPress: () => Permissions.openSettings(),
};
}
const {title, text} = this.getPermissionDeniedMessage();
Alert.alert(
title,
text,
[
grantOption,
{
text: formatMessage({
id: 'mobile.permission_denied_dismiss',
defaultMessage: 'Don\'t Allow',
}),
},
],
);
return false;
}
}
},
],
);
return false;
}
}
return true;
};
uploadFiles = async (files) => {
const file = files[0];
if (!file.fileSize | !file.fileName) {
const path = (file.path || file.uri).replace('file://', '');
const fileInfo = await RNFetchBlob.fs.stat(path);
file.fileSize = fileInfo.size;
file.fileName = fileInfo.filename;
}
if (!file.type) {
file.type = lookupMimeType(file.fileName);
}
const {validMimeTypes} = this.props;
if (validMimeTypes.length && !validMimeTypes.includes(file.type)) {
this.props.onShowUnsupportedMimeTypeWarning();
} else if (file.fileSize > this.props.maxFileSize) {
this.props.onShowFileSizeWarning(file.fileName);
} else {
this.props.uploadFiles(files);
}
};
render() {
const {theme} = this.props;
const {theme, buttonContainerStyle} = this.props;
return (
<TouchableWithFeedback
onPress={this.attachFileFromCamera}
style={style.buttonContainer}
style={buttonContainerStyle}
type={'opacity'}
>
<MaterialCommunityIcons
color={theme.centerChannelColor}
color={changeOpacity(theme.centerChannelColor, 0.64)}
name='camera-outline'
size={20}
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}
}
const style = StyleSheet.create({
buttonContainer: {
paddingLeft: 10,
paddingRight: 10,
},
});

View File

@@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert, Platform} from 'react-native';
import {shallow} from 'enzyme';
import Permissions from 'react-native-permissions';
import Preferences from 'mattermost-redux/constants/preferences';
import CameraButton from './camera_button';
jest.mock('react-intl');
jest.mock('react-native-image-picker', () => ({
launchCamera: jest.fn(),
}));
describe('CameraButton', () => {
const formatMessage = jest.fn();
const baseProps = {
fileCount: 0,
maxFileCount: 5,
onShowFileMaxWarning: jest.fn(),
theme: Preferences.THEMES.default,
uploadFiles: jest.fn(),
buttonContainerStyle: {},
};
test('should match snapshot', () => {
const wrapper = shallow(<CameraButton {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should return permission false if permission is denied in iOS', async () => {
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.UNAVAILABLE);
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
const wrapper = shallow(
<CameraButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const hasPermission = await wrapper.instance().hasCameraPermission();
expect(Permissions.check).toHaveBeenCalled();
expect(Permissions.request).toHaveBeenCalled();
expect(Alert.alert).not.toHaveBeenCalled();
expect(hasPermission).toBe(false);
});
test('should show permission denied alert and return permission false if permission is blocked in iOS', async () => {
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
jest.spyOn(Alert, 'alert').mockReturnValue(true);
const wrapper = shallow(
<CameraButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const hasPermission = await wrapper.instance().hasCameraPermission();
expect(Permissions.check).toHaveBeenCalled();
expect(Permissions.request).not.toHaveBeenCalled();
expect(Alert.alert).toHaveBeenCalled();
expect(hasPermission).toBe(false);
});
test('hasCameraPermission returns true when permission has been granted', async () => {
const platformPermissions = [{
platform: 'ios',
permission: Permissions.PERMISSIONS.IOS.CAMERA,
}, {
platform: 'android',
permission: Permissions.PERMISSIONS.ANDROID.CAMERA,
}];
for (let i = 0; i < platformPermissions.length; i++) {
const {platform, permission} = platformPermissions[i];
Platform.OS = platform;
const check = jest.spyOn(Permissions, 'check');
const request = jest.spyOn(Permissions, 'request');
request.mockReturnValue(Permissions.RESULTS.GRANTED);
const wrapper = shallow(
<CameraButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const instance = wrapper.instance();
check.mockReturnValueOnce(Permissions.RESULTS.DENIED);
let hasPermission = await instance.hasCameraPermission(); // eslint-disable-line no-await-in-loop
expect(check).toHaveBeenCalledWith(permission);
expect(request).toHaveBeenCalled();
expect(hasPermission).toBe(true);
check.mockReturnValueOnce(Permissions.RESULTS.UNAVAILABLE);
hasPermission = await instance.hasCameraPermission(); // eslint-disable-line no-await-in-loop
expect(check).toHaveBeenCalledWith(permission);
expect(request).toHaveBeenCalled();
expect(hasPermission).toBe(true);
}
});
});

View File

@@ -7,20 +7,18 @@ import {
Alert,
NativeModules,
Platform,
StyleSheet,
} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import DeviceInfo from 'react-native-device-info';
import {ICON_SIZE} from 'app/constants/post_textbox';
import AndroidOpenSettings from 'react-native-android-open-settings';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import DocumentPicker from 'react-native-document-picker';
import Permissions from 'react-native-permissions';
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
import {changeOpacity} from 'app/utils/theme';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {PermissionTypes} from 'app/constants';
const ShareExtension = NativeModules.MattermostShare;
@@ -28,15 +26,12 @@ export default class FileUploadButton extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
browseFileTypes: PropTypes.string,
validMimeTypes: PropTypes.array,
fileCount: PropTypes.number,
maxFileCount: PropTypes.number.isRequired,
maxFileSize: PropTypes.number.isRequired,
onShowFileMaxWarning: PropTypes.func,
onShowFileSizeWarning: PropTypes.func,
onShowUnsupportedMimeTypeWarning: PropTypes.func,
theme: PropTypes.object.isRequired,
uploadFiles: PropTypes.func.isRequired,
buttonContainerStyle: PropTypes.object,
};
static defaultProps = {
@@ -90,7 +85,7 @@ export default class FileUploadButton extends PureComponent {
// Decode file uri to get the actual path
res.uri = decodeURIComponent(res.uri);
this.uploadFiles([res]);
this.props.uploadFiles([res]);
} catch (error) {
// Do nothing
}
@@ -100,17 +95,17 @@ export default class FileUploadButton extends PureComponent {
hasStoragePermission = async () => {
if (Platform.OS === 'android') {
const {formatMessage} = this.context.intl;
let permissionRequest;
const hasPermissionToStorage = await Permissions.check('storage');
const storagePermission = Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE;
const hasPermissionToStorage = await Permissions.check(storagePermission);
switch (hasPermissionToStorage) {
case PermissionTypes.UNDETERMINED:
permissionRequest = await Permissions.request('storage');
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
return false;
}
break;
case PermissionTypes.DENIED: {
case Permissions.RESULTS.DENIED:
case Permissions.RESULTS.UNAVAILABLE: {
const permissionRequest = await Permissions.request(storagePermission);
return permissionRequest === Permissions.RESULTS.GRANTED;
}
case Permissions.RESULTS.BLOCKED: {
const {title, text} = this.getPermissionDeniedMessage();
Alert.alert(
@@ -130,7 +125,7 @@ export default class FileUploadButton extends PureComponent {
}),
onPress: () => AndroidOpenSettings.appDetailsSettings(),
},
]
],
);
return false;
}
@@ -140,29 +135,6 @@ export default class FileUploadButton extends PureComponent {
return true;
};
uploadFiles = async (files) => {
const file = files[0];
if (!file.fileSize | !file.fileName) {
const path = (file.path || file.uri).replace('file://', '');
const fileInfo = await RNFetchBlob.fs.stat(path);
file.fileSize = fileInfo.size;
file.fileName = fileInfo.filename;
}
if (!file.type) {
file.type = lookupMimeType(file.fileName);
}
const {validMimeTypes} = this.props;
if (validMimeTypes.length && !validMimeTypes.includes(file.type)) {
this.props.onShowUnsupportedMimeTypeWarning();
} else if (file.fileSize > this.props.maxFileSize) {
this.props.onShowFileSizeWarning(file.fileName);
} else {
this.props.uploadFiles(files);
}
};
handleButtonPress = () => {
const {
fileCount,
@@ -179,26 +151,19 @@ export default class FileUploadButton extends PureComponent {
};
render() {
const {theme} = this.props;
const {theme, buttonContainerStyle} = this.props;
return (
<TouchableWithFeedback
onPress={this.handleButtonPress}
style={style.buttonContainer}
style={buttonContainerStyle}
type={'opacity'}
>
<MaterialCommunityIcons
color={theme.centerChannelColor}
color={changeOpacity(theme.centerChannelColor, 0.64)}
name='file-document-outline'
size={20}
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}
}
const style = StyleSheet.create({
buttonContainer: {
paddingLeft: 10,
paddingRight: 10,
},
});

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert, Platform} from 'react-native';
import {shallow} from 'enzyme';
import Permissions from 'react-native-permissions';
import Preferences from 'mattermost-redux/constants/preferences';
import FileUploadButton from './file_upload_button';
jest.mock('react-intl');
jest.mock('react-native-image-picker', () => ({
launchCamera: jest.fn(),
}));
describe('FileUploadButton', () => {
const formatMessage = jest.fn();
const baseProps = {
blurTextBox: jest.fn(),
browseFileTypes: '*',
fileCount: 0,
maxFileCount: 5,
onShowFileMaxWarning: jest.fn(),
theme: Preferences.THEMES.default,
uploadFiles: jest.fn(),
buttonContainerStyle: {},
};
beforeAll(() => {
Platform.OS = 'android';
});
afterAll(() => {
Platform.OS = 'ios';
});
test('should match snapshot', () => {
const wrapper = shallow(<FileUploadButton {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should return permission false if permission is denied in Android', async () => {
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.UNAVAILABLE);
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
const wrapper = shallow(
<FileUploadButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const hasPermission = await wrapper.instance().hasStoragePermission();
expect(Permissions.check).toHaveBeenCalled();
expect(Permissions.request).toHaveBeenCalled();
expect(Alert.alert).not.toHaveBeenCalled();
expect(hasPermission).toBe(false);
});
test('should show permission denied alert and return permission false if permission is blocked in Android', async () => {
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
jest.spyOn(Alert, 'alert').mockReturnValue(true);
const wrapper = shallow(
<FileUploadButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const hasPermission = await wrapper.instance().hasStoragePermission();
expect(Permissions.check).toHaveBeenCalled();
expect(Permissions.request).not.toHaveBeenCalled();
expect(Alert.alert).toHaveBeenCalled();
expect(hasPermission).toBe(false);
});
test('hasStoragePermission returns true when permission has been granted', async () => {
const wrapper = shallow(
<FileUploadButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const instance = wrapper.instance();
const check = jest.spyOn(Permissions, 'check');
const request = jest.spyOn(Permissions, 'request');
// On iOS storage permissions are not checked
Platform.OS = 'ios';
let hasPermission = await instance.hasStoragePermission();
expect(check).not.toHaveBeenCalled();
expect(request).not.toHaveBeenCalled();
expect(hasPermission).toBe(true);
Platform.OS = 'android';
request.mockReturnValue(Permissions.RESULTS.GRANTED);
const permission = Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE;
check.mockReturnValueOnce(Permissions.RESULTS.DENIED);
hasPermission = await instance.hasStoragePermission();
expect(check).toHaveBeenCalledWith(permission);
expect(request).toHaveBeenCalled();
expect(hasPermission).toBe(true);
check.mockReturnValueOnce(Permissions.RESULTS.UNAVAILABLE);
hasPermission = await instance.hasStoragePermission();
expect(check).toHaveBeenCalledWith(permission);
expect(request).toHaveBeenCalled();
expect(hasPermission).toBe(true);
});
});

View File

@@ -6,32 +6,27 @@ import {intlShape} from 'react-intl';
import {
Alert,
Platform,
StyleSheet,
} from 'react-native';
import RNFetchBlob from 'rn-fetch-blob';
import DeviceInfo from 'react-native-device-info';
import {ICON_SIZE} from 'app/constants/post_textbox';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import ImagePicker from 'react-native-image-picker';
import Permissions from 'react-native-permissions';
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
import {changeOpacity} from 'app/utils/theme';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {PermissionTypes} from 'app/constants';
export default class ImageUploadButton extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
validMimeTypes: PropTypes.array,
fileCount: PropTypes.number,
maxFileCount: PropTypes.number.isRequired,
maxFileSize: PropTypes.number.isRequired,
onShowFileMaxWarning: PropTypes.func,
onShowFileSizeWarning: PropTypes.func,
onShowUnsupportedMimeTypeWarning: PropTypes.func,
theme: PropTypes.object.isRequired,
uploadFiles: PropTypes.func.isRequired,
buttonContainerStyle: PropTypes.object,
};
static defaultProps = {
@@ -104,84 +99,56 @@ export default class ImageUploadButton extends PureComponent {
return;
}
this.uploadFiles([response]);
this.props.uploadFiles([response]);
});
}
};
hasPhotoPermission = async () => {
if (Platform.OS === 'ios') {
const {formatMessage} = this.context.intl;
let permissionRequest;
const targetSource = 'photo';
const hasPermissionToStorage = await Permissions.check(targetSource);
const {formatMessage} = this.context.intl;
const targetSource = Platform.OS === 'ios' ?
Permissions.PERMISSIONS.IOS.PHOTO_LIBRARY :
Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE;
const hasPermission = await Permissions.check(targetSource);
switch (hasPermissionToStorage) {
case PermissionTypes.UNDETERMINED:
permissionRequest = await Permissions.request(targetSource);
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
return false;
}
break;
case PermissionTypes.DENIED: {
const canOpenSettings = await Permissions.canOpenSettings();
let grantOption = null;
if (canOpenSettings) {
grantOption = {
switch (hasPermission) {
case Permissions.RESULTS.DENIED:
case Permissions.RESULTS.UNAVAILABLE: {
const permissionRequest = await Permissions.request(targetSource);
return permissionRequest === Permissions.RESULTS.GRANTED;
}
case Permissions.RESULTS.BLOCKED: {
const grantOption = {
text: formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
}),
onPress: () => Permissions.openSettings(),
};
const {title, text} = this.getPermissionDeniedMessage();
Alert.alert(
title,
text,
[
grantOption,
{
text: formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
id: 'mobile.permission_denied_dismiss',
defaultMessage: 'Don\'t Allow',
}),
onPress: () => Permissions.openSettings(),
};
}
const {title, text} = this.getPermissionDeniedMessage();
Alert.alert(
title,
text,
[
grantOption,
{
text: formatMessage({
id: 'mobile.permission_denied_dismiss',
defaultMessage: 'Don\'t Allow',
}),
},
],
);
return false;
}
}
},
],
);
return false;
}
}
return true;
};
uploadFiles = async (files) => {
const file = files[0];
if (!file.fileSize | !file.fileName) {
const path = (file.path || file.uri).replace('file://', '');
const fileInfo = await RNFetchBlob.fs.stat(path);
file.fileSize = fileInfo.size;
file.fileName = fileInfo.filename;
}
if (!file.type) {
file.type = lookupMimeType(file.fileName);
}
const {validMimeTypes} = this.props;
if (validMimeTypes.length && !validMimeTypes.includes(file.type)) {
this.props.onShowUnsupportedMimeTypeWarning();
} else if (file.fileSize > this.props.maxFileSize) {
this.props.onShowFileSizeWarning(file.fileName);
} else {
this.props.uploadFiles(files);
}
};
handleButtonPress = () => {
const {
fileCount,
@@ -199,26 +166,19 @@ export default class ImageUploadButton extends PureComponent {
};
render() {
const {theme} = this.props;
const {theme, buttonContainerStyle} = this.props;
return (
<TouchableWithFeedback
onPress={this.handleButtonPress}
style={style.buttonContainer}
style={buttonContainerStyle}
type={'opacity'}
>
<MaterialCommunityIcons
color={theme.centerChannelColor}
color={changeOpacity(theme.centerChannelColor, 0.64)}
name='image-outline'
size={20}
size={ICON_SIZE}
/>
</TouchableWithFeedback>
);
}
}
const style = StyleSheet.create({
buttonContainer: {
paddingLeft: 10,
paddingRight: 10,
},
});

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert, Platform} from 'react-native';
import {shallow} from 'enzyme';
import Permissions from 'react-native-permissions';
import Preferences from 'mattermost-redux/constants/preferences';
import ImageUploadButton from './image_upload_button';
jest.mock('react-intl');
jest.mock('react-native-image-picker', () => ({
launchCamera: jest.fn(),
}));
describe('ImageUploadButton', () => {
const formatMessage = jest.fn();
const baseProps = {
blurTextBox: jest.fn(),
fileCount: 0,
maxFileCount: 5,
onShowFileMaxWarning: jest.fn(),
theme: Preferences.THEMES.default,
uploadFiles: jest.fn(),
buttonContainerStyle: {},
};
test('should match snapshot', () => {
const wrapper = shallow(<ImageUploadButton {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should return permission false if permission is denied in iOS', async () => {
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.UNAVAILABLE);
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
const wrapper = shallow(
<ImageUploadButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const hasPermission = await wrapper.instance().hasPhotoPermission();
expect(Permissions.check).toHaveBeenCalled();
expect(Permissions.request).toHaveBeenCalled();
expect(Alert.alert).not.toHaveBeenCalled();
expect(hasPermission).toBe(false);
});
test('should show permission denied alert and return permission false if permission is blocked in iOS', async () => {
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
jest.spyOn(Alert, 'alert').mockReturnValue(true);
const wrapper = shallow(
<ImageUploadButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const hasPermission = await wrapper.instance().hasPhotoPermission();
expect(Permissions.check).toHaveBeenCalled();
expect(Permissions.request).not.toHaveBeenCalled();
expect(Alert.alert).toHaveBeenCalled();
expect(hasPermission).toBe(false);
});
test('hasPhotoPermission returns true when permission has been granted', async () => {
const platformPermissions = [{
platform: 'ios',
permission: Permissions.PERMISSIONS.IOS.PHOTO_LIBRARY,
}, {
platform: 'android',
permission: Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE,
}];
for (let i = 0; i < platformPermissions.length; i++) {
const {platform, permission} = platformPermissions[i];
Platform.OS = platform;
const check = jest.spyOn(Permissions, 'check');
const request = jest.spyOn(Permissions, 'request');
request.mockReturnValue(Permissions.RESULTS.GRANTED);
const wrapper = shallow(
<ImageUploadButton {...baseProps}/>,
{context: {intl: {formatMessage}}},
);
const instance = wrapper.instance();
check.mockReturnValueOnce(Permissions.RESULTS.DENIED);
let hasPermission = await instance.hasPhotoPermission(); // eslint-disable-line no-await-in-loop
expect(check).toHaveBeenCalledWith(permission);
expect(request).toHaveBeenCalled();
expect(hasPermission).toBe(true);
check.mockReturnValueOnce(Permissions.RESULTS.UNAVAILABLE);
hasPermission = await instance.hasPhotoPermission(); // eslint-disable-line no-await-in-loop
expect(check).toHaveBeenCalledWith(permission);
expect(request).toHaveBeenCalled();
expect(hasPermission).toBe(true);
}
});
});

View File

@@ -55,7 +55,6 @@ function mapStateToProps(state, ownProps) {
channelTeamId: currentChannel ? currentChannel.team_id : '',
canUploadFiles: canUploadFilesOnMobile(state),
channelDisplayName: state.views.channel.displayName || (currentChannel ? currentChannel.display_name : ''),
channelIsLoading: state.views.channel.loading,
channelIsReadOnly: isCurrentChannelReadOnly(state) || false,
channelIsArchived: ownProps.channelIsArchived || (currentChannel ? currentChannel.delete_at !== 0 : false),
currentUserId,

View File

@@ -14,9 +14,9 @@ import PasteableTextInput from 'app/components/pasteable_text_input';
import EphemeralStore from 'app/store/ephemeral_store';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import FileUploadButton from './components/fileUploadButton';
import ImageUploadButton from './components/imageUploadButton';
import CameraButton from './components/cameraButton';
import FileUploadButton from './components/file_upload_button';
import ImageUploadButton from './components/image_upload_button';
import CameraButton from './components/camera_button';
import PostTextbox from './post_textbox.ios';
@@ -46,7 +46,6 @@ describe('PostTextBox', () => {
channelId: 'channel-id',
channelDisplayName: 'Test Channel',
channelTeamId: 'channel-team-id',
channelIsLoading: false,
channelIsReadOnly: false,
currentUserId: 'current-user-id',
deactivatedChannel: false,
@@ -344,7 +343,7 @@ describe('PostTextBox', () => {
mockResolvedValue({data: 'success'});
const wrapper = shallowWithIntl(
<PostTextbox {...props}/>
<PostTextbox {...props}/>,
);
const msg = '/fail preserve this text in the post draft';

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