Compare commits

...

78 Commits

Author SHA1 Message Date
enahum
06aa01507f Release 1.1 changelog (#827)
* Release 1.1 changelog

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

Set supported server version
2017-08-04 16:36:42 -04:00
enahum
60bf695789 Version Bump to 47 (#825) 2017-08-04 11:13:51 -04:00
enahum
24eaef38ef Version Bump to 47 (#824) 2017-08-04 10:56:43 -04:00
Harrison Healey
53ae78674e RN-205/RN-210 Allow tapping buttons on post and DM lists without closing keyboard (#823) 2017-08-04 09:14:57 -04:00
enahum
d7ef19f883 Version Bump to 46 (#822) 2017-08-04 09:09:38 -04:00
enahum
15e05c44f5 Version Bump to 46 (#821) 2017-08-04 09:07:31 -04:00
enahum
12f6b11e09 Fix Create on Create channel to not push another Create Channel screen (#820) 2017-08-03 18:14:44 -04:00
enahum
10fd389e15 Android Version Bump to 45 (#819)
* Updated yarn.lock (#817)

* Version Bump to 45
2017-08-03 18:09:44 -04:00
enahum
e50049d0d6 Version Bump to 45 (#818) 2017-08-03 18:09:36 -04:00
enahum
9d3e072f7a Fastlane ensure branch for cutting builds (#815) 2017-08-03 16:25:58 -04:00
Harrison Healey
537598cc64 Updated yarn.lock 2017-08-03 16:22:30 -04:00
Harrison Healey
9c0ff207ab PLT-7250 Updated commonmark.js to better handle trailing periods on URLs (#814) 2017-08-02 11:04:56 -04:00
enahum
bdaecfc8dd Version Bump to 44 (#811) 2017-08-02 09:42:25 -04:00
enahum
8939a691eb Version Bump to 44 (#810) 2017-08-02 09:42:14 -04:00
enahum
01ab4af1e2 Fix retry to get posts race condition (#809) 2017-08-02 09:37:50 -04:00
Chris Duarte
fd0ca606bb Use post retry actions to avoid missing messages (#796)
* Use post retry actions to avoid missing messages

* Review feedback

* Fix race condition and update redux-offline

* Review feedback
2017-08-01 19:09:57 -04:00
Harrison Healey
e30ed26977 Update readme to use assets/override instead of config.secret.json (#808) 2017-08-01 19:05:59 -04:00
enahum
f6bdbb941a Fix new message indicator in channel screen (#807) 2017-08-01 18:21:26 -04:00
enahum
06e50baf79 translations PR 20170731 (#805) 2017-08-01 17:40:49 -04:00
enahum
2216843854 Fixes autocomplete to not to crash if rootId is undefined (#804) 2017-07-31 18:39:10 -04:00
enahum
d5513a4585 Fix localized string (#803) 2017-07-31 18:38:42 -04:00
enahum
561140c034 Version Bump to 43 (#802) 2017-07-29 11:10:03 -04:00
enahum
3b7a570b60 Version Bump to 43 (#801) 2017-07-29 11:09:52 -04:00
enahum
95a8b46242 RN-293 Show no resuls found in search for posts (#800)
* RN-293 Show no resuls found in search for posts

* Update mattermost-redux

* remove unnecesary array for style
2017-07-28 17:16:25 -04:00
Harrison Healey
0f8f93af6d PLT-6924 Added setting to disable mobile file uploads (#799)
* PLT-6924 Added setting to disable mobile file uploads

* PLT-6924 Updated yarn.lock
2017-07-28 17:06:44 -04:00
Chris Duarte
ab2144c423 RN-217 Add emoji autocomplete (#798)
* RN-217 Add emoji autocomplete

* Review feedback
2017-07-28 16:49:46 -04:00
enahum
8a3e410995 RN-246 include NOTICE links in about screen (#777)
* RN-246 include NOTICE links in about screen

* Feedback review
2017-07-28 09:51:11 -04:00
Harrison Healey
8a7840b58a RN-152 Add ability to create new group direct messages (#793)
* RN-152 Added ability to open group messages from MoreDirectMessages

* Refactored row components for CustomList

* Added CustomSectionList component to mirror CustomList

* Added control to display all selected users in More DMs list

* Updated title of More DMs modal

* Updated yarn.lock

* Fixed channel name not being set when creating GM channel

* Fixed error on Android
2017-07-27 19:08:13 -04:00
enahum
4506e13c95 Fix sso login onMessage handler (#787)
* Fix sso login onMessage handler

* Remove unnecessary commented line
2017-07-27 18:43:52 -04:00
enahum
953a2718f4 RN-285 Rename clear offline store to reset cache and moved to advanced settings screen (#791) 2017-07-27 18:43:38 -04:00
enahum
03fabc45cc Fix sticky (#795)
* Last item in search results displays with no buffer below

* Search sticky headers only for ios
2017-07-27 18:43:25 -04:00
enahum
ec27c391f2 translations PR 20170724 (#778) 2017-07-27 18:39:47 -04:00
enahum
1716c1127b Make search results scroll after a period of time (#792) 2017-07-27 18:33:38 -04:00
enahum
8e0d1f56d9 Last item in search results displays with no buffer below (#794) 2017-07-27 18:33:20 -04:00
lfbrock
64f036982e Draft Changelog (#788) 2017-07-27 18:30:31 -04:00
enahum
a39ff41ff7 Fix Title in "Jump" pop up is not visible on default theme (#790) 2017-07-27 18:28:55 -04:00
enahum
3315ce9328 Adjust search box carret and selection color (#789) 2017-07-27 18:28:40 -04:00
enahum
28ce84e666 Version Bump to 42 (#781) 2017-07-25 09:09:03 -04:00
enahum
e86fc138d5 Version Bump to 42 (#780) 2017-07-25 09:08:53 -04:00
enahum
d7bf52f8bb Set version as 1.1.0 (#779) 2017-07-25 08:46:49 -04:00
enahum
c8b59895fa Android Notifications Support for Big View (#774) 2017-07-24 08:25:47 -04:00
enahum
6c7523b9e5 Add mattermost:// url scheme to ios (#769) 2017-07-24 08:15:34 -04:00
enahum
9d6d5f41a5 Fix GM channel info memberCount (#775) 2017-07-24 08:12:13 -04:00
Chris Duarte
b6274da38a RN-270 "Jump to conversation" filtering (#755)
* RN-270 "Jump to conversation" filtering

* Review Feedback

* Change reduce filter for member details

* Remove duplicate email push
2017-07-24 08:11:52 -04:00
Harrison Healey
a5f10c7137 PLT-244/PLT-277 Made Delete Channel visibility always depend on policy setting (#771)
* PLT-274 Fixed navigator not being passed into ChannelInfo Markdown

* PLT-244/PLT-277 Changed Delete Channel visibility to always be based on policy settings
2017-07-21 18:29:08 -04:00
Harrison Healey
5e0ca727a5 PLT-253 Made margins around settings button appear even (#770) 2017-07-21 18:28:30 -04:00
Harrison Healey
2a9e1ee7a3 RN-245 Made all channel icons have equal margins (#765)
* RN-245 Made all channel icons have equal margins

* Fixed offline icon being white-on-white in channel info
2017-07-21 17:10:40 -04:00
Elias Nahum
ee148e7472 RN-249 Fix the hamburger icon opening the drawer when it was closed by swiping to close 2017-07-21 17:10:15 -04:00
Elias Nahum
3f18238597 Fix search box clear button to be more tappable and center text vertically 2017-07-21 17:09:34 -04:00
enahum
dc8b9a04b1 RN-10 Ability to search Posts (#763)
* Fix offline indicator UI

* Add search screen with recent mentions functionality

* Fix make start

* Add autocomplete to the search box

* Fix search bar in other screens

* Get search results and scroll the list

* Add reply arrow to search result posts

* Search result preview and jump to channel

* Feedback review
2017-07-21 17:07:47 -04:00
Chris Duarte
aafc8001dc RN-248 Upload file and leave channel (#759)
* RN-248 Upload file and leave channel

* Fix bool to string comparison
2017-07-21 09:49:30 -04:00
Elias Nahum
374dccd770 Update version to 1.1 2017-07-21 09:48:00 -04:00
lfbrock
ef2cddd65d Update CHANGELOG.md (#760) 2017-07-20 14:54:08 -04:00
Elias Nahum
6e9faa95db Include build folders in make clean 2017-07-20 12:50:53 -04:00
Elias Nahum
0ae79b4cf4 Update fastlane 2017-07-20 12:50:53 -04:00
enahum
f23f8d99df Fix SSO login (#754)
* Fix SSO login

* Update style

* Fix stripTrailingSlashes
2017-07-20 12:50:53 -04:00
enahum
54f5d77fa6 fix for huawei not rendering posts (#751) 2017-07-20 12:50:53 -04:00
Elias Nahum
645d7775ab Prepare for dot release 1.0.1 2017-07-20 12:50:53 -04:00
Chris Duarte
67840c0b6f RN-256 Image thumbnails are blurry (#756) 2017-07-20 09:54:43 -04:00
Chris Duarte
e34f82a36a Android Leaving/Deleting channel redirects to info page of Town Square (#750) 2017-07-17 14:56:09 -04:00
Chris Duarte
daa193e2ce Disable the send button while attachments are uploading (#749) 2017-07-17 14:55:44 -04:00
Chris Duarte
9db0a38e8b RN-153 Show emoji reactions on posts (#745)
* RN-153 Show emoji reactions on posts

* Review Feedback

* Change makeMapStateToProps in export
2017-07-17 12:27:16 -04:00
enahum
93d7697dcc RN-261 Fix app crashing when badge is unmounted before it displays (#743) 2017-07-17 12:26:53 -04:00
lfbrock
d98eb99f82 Update README.md (#747) 2017-07-14 14:46:37 -04:00
enahum
1aca62bf87 translations PR 20170714 (#744) 2017-07-14 12:58:30 -04:00
enahum
4d58524e5d translations PR 20170710 (#732) 2017-07-14 08:57:40 -04:00
enahum
088ecad2b5 Split Post component into smaller components and prevent re-rendering (#740)
* Split Post component into smaller components and prevent re-rendering

* Feedback review

* Fix render reply bar

* Remove eslint-disable-line
2017-07-13 17:48:45 -04:00
lfbrock
2567c33a2f Update README.md (#741) 2017-07-13 16:33:09 -04:00
lfbrock
7e9471a16d Update CHANGELOG.md (#739) 2017-07-13 11:16:18 -04:00
lfbrock
98a7ef1eaf Update ReadMe (#731) 2017-07-13 11:15:47 -04:00
Chris Duarte
422abe4cb7 Fix undefined object issue in Jump to Conversation (#736) 2017-07-12 15:27:34 -04:00
Stan Chan
643925469b RN-38 Clicking on an at mention should open user profile (#728) 2017-07-10 18:09:15 -04:00
enahum
b65c7ce3be Android Version Bump to 40 (#730)
* Android fastlane fixes

* Version Bump to 40
2017-07-10 14:22:02 -04:00
enahum
431059d96d Version Bump to 40 (#729) 2017-07-10 14:21:53 -04:00
Stan Chan
b03189defb Refactor Jump to Conversation (#726)
* Refactor Jump to Conversation

Fixed channel loader not showing when switching channels
Refactored jump to conversation

* Review feedback
2017-07-10 08:30:51 -04:00
Stan Chan
c017afb392 RN-66 Clear cache store (#727)
* RN-66 Clear cache store

* Review feedback
2017-07-07 18:21:48 -04:00
enahum
458a2be333 Fix race condition when selecting channel in PN (#723)
* Fix race condition when selecting channel in PN

* Feedback review
2017-07-07 16:37:31 -04:00
enahum
8afd7fe1cd Renaming the bundle and package Ids (#721)
* Renamed to com.mattermost.rnbeta

* Fastlane for release of com.mattermost.rn

* Do not Publish apps directly to production

* Change ios icon format from jpeg to png
2017-07-07 16:37:05 -04:00
177 changed files with 7847 additions and 2909 deletions

View File

@@ -1,13 +1,67 @@
# Mattermost Mobile Apps Changelog
## v1.1 Release
- Release Date: August 2017
- Server Versions Supported: Server v3.10+ is required, Self-Signed SSL Certificates are not yet supported
### Highlights
#### Search
- Search posts and tap to preview the result
- Click "Jump" to open the channel the search result is from
#### Emoji Reactions
- View Emoji Reactions on a post
#### Group Messages
- Start Direct and Group Messages from the same screen
#### Improved Performance on Poor Connections
- Added auto-retry to automatically reattempt to get posts if the network connection is intermittent
- Added manual loading option if auto-retry fails to retrieve new posts
### Improvements
- Android: Added Big Text support for Android notifications, so they expand to show more details
- Added a Reset Cache option
- Improved "Jump to conversation" filter so it matches on nickname, full name, or username
- Tapping on an @username mention opens the user's profile
- Disabled the send button while attachments upload
- Adjusted margins on icons and elsewhere to make spacing more consistent
- iOS URL scheme: mattermost:// links now open the new app
- About Mattermost page now includes a link to NOTICES.txt for platform and the mobile app
- Various UI improvements
### Bug Fixes
- Fixed an issue where sometimes an unmounted badge caused app to crash on start up
- Group Direct Messages now show the correct member count
- Hamburger icon does not break after swiping to close sidebar
- Fixed an issue with some image thumbnails appearing out of focus
- Uploading a file and then leaving the channel no longer shows the file in a perpetual loading state
- For private channels, the last member can no longer delete the channel if the EE server permissions do not allow it
- Error messages are now shown when SSO login fails
- Android: Leaving a channel now redirects to Town Square instead of the Town Square info page
- Fixed create new public channel screen shown twice when trying to create a channel
- Tapping on a post will no longer close the keyboard
## v1.0.1 Release
- Release Date: July 20, 2017
- Server Versions Supported: Server v3.8+ is required, Self-Signed SSL Certificates are not yet supported
### Bug Fixes
- Huawei devices can now load messages
- GitLab SSO now works if there is a trailing `/` in the server URL
- Unsupported server versions now show a prompt clarifying that a server upgrade is necessary
## v1.0 Release
- Planned Release Date: July 5, 2017
- Release Date: July 10, 2017
- Server Versions Supported: Server v3.8+ is required, Self-Signed SSL Certificates are not yet supported
### Highlights
#### Authentication
#### Authentication (Requires v3.10+ [Mattermost server](https://github.com/mattermost/platform))
- GitLab login
#### Offline Support

View File

@@ -90,6 +90,8 @@ clean:
rm -rf node_modules
rm -f .yarninstall
rm -rf dist
rm -rf ios/build
rm -rf android/app/build
post-install:
./node_modules/.bin/remotedev-debugger --hostname localhost --port 5678 --injectserver
@@ -103,12 +105,12 @@ post-install:
sed -i'' -e 's|"./locale-data/complete.js": false|"./locale-data/complete.js": "./locale-data/complete.js"|g' node_modules/intl/package.json
start-packager:
@if [ $(shell ps -a | grep "cli.js start" | grep -civ grep) -eq 0 ]; then \
@if [ $(shell ps -e | grep -i "cli.js start" | grep -civ grep) -eq 0 ]; then \
echo Starting React Native packager server; \
node ./node_modules/react-native/local-cli/cli.js start --reset-cache & echo $$! > server.PID; \
else \
echo React Native packager server already running; \
ps -a | grep -i "cli.js start" | grep -v grep | awk '{print $$1}' > server.PID; \
ps -e | grep -i "cli.js start" | grep -v grep | awk '{print $$1}' > server.PID; \
fi
stop-packager:

View File

@@ -1,32 +1,30 @@
# Mattermost Mobile (unreleased)
# Mattermost Mobile
**Supported Server Versions:** 3.8+
This is an unreleased project for replacing the Mattermost iOS and Android apps with new mobile apps using React Native and Redux. The project is currently in beta, with a planned release date of July 2017.
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 11 languages. Learn more at https://mattermost.com.
You can download our apps from the [App Store](https://about.mattermost.com/mattermost-ios-app/) or [Google Play Store](https://about.mattermost.com/mattermost-android-app/), or package them yourself.
We plan on releasing monthly updates with new features - check the [changelog](https://github.com/mattermost/mattermost-mobile/blob/master/CHANGELOG.md) for what features are currently supported!
# How to Contribute
### Testing
The apps are currently in beta [(released on March 29, 2017)](https://github.com/mattermost/mattermost-mobile/blob/master/CHANGELOG.md#beta-release). We cut new builds regularly, so people can test it out and see what's new.
To help with testing app updates before they're released, you can:
If you would like to help with testing the apps, you can:
1. (Optional) [Sign up for our team site](https://pre-release.mattermost.com/signup_user_complete/?id=f1924a8db44ff3bb41c96424cdc20676)
- Join the [Native Mobile Apps channel](https://pre-release.mattermost.com/core/channels/native-mobile-apps) to see what's new and discuss feedback with the contributors and the core team
2. Sign up to be a beta tester
- [Android](https://play.google.com/apps/testing/com.mattermost.react.native)
1. Sign up to be a beta tester
- [Android](https://play.google.com/apps/testing/com.mattermost.rnbeta)
- [iOS](https://mattermost-fastlane.herokuapp.com/)
3. Install the `Mattermost 2017 (Beta)` app
- Note: If your server version is not compatible, you can test using our team site [https://pre-release.mattermost.com/](https://pre-release.mattermost.com/)
4. Check the [changelog](https://github.com/mattermost/mattermost-mobile/blob/master/CHANGELOG.md) for what's currently supported
5. File any bugs you find by filing a [GitHub issue](https://github.com/mattermost/mattermost-mobile/issues) with:
2. Install the `Mattermost Beta` app
3. File any bugs you find by filing a [GitHub issue](https://github.com/mattermost/mattermost-mobile/issues) with:
- Device information
- Repro steps
- Observed behavior (including screenshot / video when possible)
- Expected behavior
4. (Optional) [Sign up for our team site](https://pre-release.mattermost.com/signup_user_complete/?id=f1924a8db44ff3bb41c96424cdc20676)
- Join the [Native Mobile Apps channel](https://pre-release.mattermost.com/core/channels/native-mobile-apps) to see what's new and discuss feedback with other contributors and the core team
### Contribute Code
@@ -36,6 +34,7 @@ If you would like to help with testing the apps, you can:
4. Join the [Native Mobile Apps channel](https://pre-release.mattermost.com/core/channels/native-mobile-apps) on our team site to ask questions
# Installing Dependencies
Follow the [React Native Getting Started Guide](https://facebook.github.io/react-native/docs/getting-started.html) for detailed instructions on setting up your local machine for development.
# Detailed configuration:
@@ -98,7 +97,7 @@ Follow the [React Native Getting Started Guide](https://facebook.github.io/react
$ npm install -g react-native-cli
```
- Add or edit file `src/config/config.secret.json` and add the url to the Mattermost server that you will use to develop:
- You can create a file named `assets/override/config.json` and add the url to the Mattermost server that you will use to develop:
`{
"DefaultServerUrl": "https://pre-release.mattermost.com"
}`
@@ -115,6 +114,32 @@ Follow the [React Native Getting Started Guide](https://facebook.github.io/react
# Frequently Asked Questions
**How is data handled on mobile devices after a user account is deactivated?**
### How is data handled on mobile devices after a user account is deactivated?
App data is wiped from the device when a user logs out of the app. If the user is logged in when the account is deactivated, then within one minute the system logs the user out, and as a result all app data is wiped from the device.
### Can I connect to multiple Mattermost servers using the mobile apps?**
At the moment, we only support connecting to one server at a time. If you need to connect to multiple servers, please [upvote the feature request](https://mattermost.uservoice.com/forums/306457/suggestions/10975938) so we can track demand for it.
As a work around, you can install both the released "Mattermost" app and sign up to be a [tester](#testing) for the "Mattermost Beta" app so you can connect to two servers at once.
### Will there be second generation apps available for tablets?**
We plan to add support for tablets in the future, but the timeline depends on how many people have a need for it. If you're looking for a tablet version, please help us out by [upvoting the feature request](https://mattermost.uservoice.com/forums/306457/suggestions/20082079)!
# Troubleshooting
### I keep getting a message "Cannot connect to the server. Please check your server URL and internet connection."
Our second generation mobile apps only support server versions 3.8+. If your server version is too old, you might see this error message come up.
To check your server version, log on to the site on desktop and go to Main Menu > About Mattermost.
### I see a “Connecting…” bar that does not go away
If your app is working properly, you should see a grey “Connecting…” bar that clears or says “Connected” after the app reconnects.
If you are seeing this message all the time, and your internet connection seems fine:
Ask your server administrator if the server uses NGINX or another webserver as a reverse proxy. If so, they should check that it is configured correctly for [supporting the websocket connection for APIv4 endpoints](https://docs.mattermost.com/install/install-ubuntu-1604.html#configuring-nginx-as-a-proxy-for-mattermost-server).

View File

@@ -46,13 +46,13 @@ android_library(
android_build_config(
name = 'build_config',
package = 'com.mattermost',
package = 'com.mattermost.rnbeta',
)
android_resource(
name = 'res',
res = 'src/main/res',
package = 'com.mattermost',
package = 'com.mattermost.rnbeta',
)
android_binary(

View File

@@ -88,11 +88,11 @@ android {
buildToolsVersion "25.0.1"
defaultConfig {
applicationId "com.mattermost.react.native"
applicationId "com.mattermost.rnbeta"
minSdkVersion 16
targetSdkVersion 23
versionCode 39
versionName "1.0"
versionCode 47
versionName "1.1.0"
multiDexEnabled true
ndk {
abiFilters "armeabi-v7a", "x86"

View File

@@ -36,6 +36,68 @@
"status": 2
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:184930218130:android:c7debfa7ea3f75a7",
"android_client_info": {
"package_name": "com.mattermost.rnbeta"
}
},
"oauth_client": [
{
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
}
],
"services": {
"analytics_service": {
"status": 1
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:184930218130:android:2db4058a5b5d6506",
"android_client_info": {
"package_name": "com.mattermost.rn"
}
},
"oauth_client": [
{
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE"
}
],
"services": {
"analytics_service": {
"status": 1
},
"appinvite_service": {
"status": 1,
"other_platform_oauth_client": []
},
"ads_service": {
"status": 2
}
}
}
],
"configuration_version": "1"

View File

@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.mattermost"
package="com.mattermost.rnbeta"
android:versionCode="1"
android:versionName="1.0">
@@ -42,8 +42,7 @@
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
<receiver android:name=".NotificationDismissReceiver" />
</application>
</manifest>

View File

@@ -1,6 +1,7 @@
package com.mattermost;
package com.mattermost.rnbeta;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.Context;
import android.content.res.Resources;
import android.content.pm.ApplicationInfo;
@@ -11,6 +12,7 @@ import android.os.Build;
import android.app.Notification;
import android.app.NotificationManager;
import java.util.LinkedHashMap;
import java.util.ArrayList;
import com.wix.reactnativenotifications.core.notification.PushNotification;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
@@ -24,12 +26,22 @@ public class CustomPushNotification extends PushNotification {
public static final int MESSAGE_NOTIFICATION_ID = 435345;
public static final String GROUP_KEY_MESSAGES = "mm_group_key_messages";
public static final String NOTIFICATION_ID = "notificationId";
private static LinkedHashMap<String,Integer> channelIdToNotificationCount = new LinkedHashMap<String,Integer>();
private static LinkedHashMap<String,ArrayList<Bundle>> channelIdToNotification = new LinkedHashMap<String,ArrayList<Bundle>>();
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper);
}
public static void clearNotification(int notificationId) {
if (notificationId != -1) {
String channelId = String.valueOf(notificationId);
channelIdToNotificationCount.remove(channelId);
channelIdToNotification.remove(channelId);
}
}
@Override
public void onReceived() throws InvalidNotificationException {
Bundle data = mNotificationProps.asBundle();
@@ -44,6 +56,16 @@ public class CustomPushNotification extends PushNotification {
count = (Integer)objCount + 1;
}
channelIdToNotificationCount.put(channelId, count);
Object bundleArray = channelIdToNotification.get(channelId);
ArrayList list = null;
if (bundleArray == null) {
list = new ArrayList();
} else {
list = (ArrayList)bundleArray;
}
list.add(data);
channelIdToNotification.put(channelId, list);
}
if ("clear".equals(type)) {
@@ -55,6 +77,16 @@ public class CustomPushNotification extends PushNotification {
notifyReceivedToJS();
}
@Override
public void onOpened() {
Bundle data = mNotificationProps.asBundle();
final String channelId = data.getString("channel_id");
channelIdToNotificationCount.remove(channelId);
channelIdToNotification.remove(channelId);
digestNotification();
clearAllNotifications();
}
@Override
protected void postNotification(int id, Notification notification) {
if (!mAppLifecycleFacade.isAppVisible()) {
@@ -118,19 +150,40 @@ public class CustomPushNotification extends PushNotification {
}
notification
.setContentTitle(title)
.setContentText(message)
.setGroupSummary(true)
.setSmallIcon(smallIconResId)
.setVisibility(Notification.VISIBILITY_PRIVATE)
.setPriority(Notification.PRIORITY_HIGH);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
notification.setGroup(GROUP_KEY_MESSAGES);
if (numMessages == 1) {
notification
.setContentTitle(title)
.setContentText(message)
.setStyle(new Notification.BigTextStyle()
.bigText(message));
} else {
String summaryTitle = String.format("%s (%d)", title, numMessages);
Notification.InboxStyle style = new Notification.InboxStyle();
ArrayList<Bundle> list = (ArrayList<Bundle>) channelIdToNotification.get(channelId);
for (Bundle data : list){
style.addLine(data.getString("message"));
}
style.setBigContentTitle(title);
notification.setStyle(style)
.setContentTitle(summaryTitle);
// .setNumber(numMessages);
}
if (numMessages > 1) {
notification.setNumber(numMessages);
// Let's add a delete intent when the notification is dismissed
Intent delIntent = new Intent(mContext, NotificationDismissReceiver.class);
delIntent.putExtra(NOTIFICATION_ID, notificationId);
PendingIntent deleteIntent = PendingIntent.getBroadcast(mContext, 0, delIntent, 0);
notification.setDeleteIntent(deleteIntent);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
notification.setGroup(GROUP_KEY_MESSAGES);
}
Bitmap largeIconBitmap = BitmapFactory.decodeResource(res, largeIconResId);
@@ -158,6 +211,7 @@ public class CustomPushNotification extends PushNotification {
}
channelIdToNotificationCount.remove(channelId);
channelIdToNotification.remove(channelId);
final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(notificationId);
}

View File

@@ -1,4 +1,4 @@
package com.mattermost;
package com.mattermost.rnbeta;
import com.github.yamill.orientation.OrientationPackage;
import com.psykar.cookiemanager.CookieManagerPackage;

View File

@@ -1,4 +1,4 @@
package com.mattermost;
package com.mattermost.rnbeta;
import android.app.Application;
import android.util.Log;

View File

@@ -0,0 +1,14 @@
package com.mattermost.rnbeta;
import android.content.Context;
import android.content.Intent;
import android.content.BroadcastReceiver;
public class NotificationDismissReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
int notificationId = intent.getIntExtra(CustomPushNotification.NOTIFICATION_ID, -1);
CustomPushNotification.clearNotification(notificationId);
}
}

View File

@@ -1,4 +1,4 @@
package com.mattermost;
package com.mattermost.rnbeta;
import android.app.Activity;
import android.util.Log;

View File

@@ -1,5 +1,6 @@
<?xml version="1.0"?>
<resources>
<string name="app_name">Mattermost</string>
<string name="app_name">Mattermost Beta</string>
</resources>

View File

@@ -6,7 +6,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:2.2.+'
classpath 'com.google.gms:google-services:3.0.0'
classpath 'com.google.gms:google-services:3.1.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files

View File

@@ -12,7 +12,7 @@ import {
selectChannel,
leaveChannel as serviceLeaveChannel
} from 'mattermost-redux/actions/channels';
import {getPosts, getPostsBefore, getPostsSince} from 'mattermost-redux/actions/posts';
import {getPosts, getPostsWithRetry, getPostsBefore, getPostsSinceWithRetry, getPostThread} from 'mattermost-redux/actions/posts';
import {getFilesForPost} from 'mattermost-redux/actions/files';
import {savePreferences, deletePreferences} from 'mattermost-redux/actions/preferences';
import {getTeamMembersByIds} from 'mattermost-redux/actions/teams';
@@ -136,8 +136,8 @@ export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
};
}
export function loadPostsIfNecessary(channelId) {
return async (dispatch, getState) => {
export function loadPostsIfNecessaryWithRetry(channelId) {
return (dispatch, getState) => {
const state = getState();
const {posts, postsInChannel} = state.entities.posts;
@@ -145,23 +145,35 @@ export function loadPostsIfNecessary(channelId) {
// Get the first page of posts if it appears we haven't gotten it yet, like the webapp
if (!postsIds || postsIds.length < ViewTypes.POST_VISIBILITY_CHUNK_SIZE) {
return getPosts(channelId)(dispatch, getState);
getPostsWithRetry(channelId)(dispatch, getState);
return;
}
const postsForChannel = postsIds.map((id) => posts[id]);
const latestPostTime = getLastCreateAt(postsForChannel);
return getPostsSince(channelId, latestPostTime)(dispatch, getState);
getPostsSinceWithRetry(channelId, latestPostTime)(dispatch, getState);
};
}
export function loadFilesForPostIfNecessary(post) {
export function loadFilesForPostIfNecessary(postId) {
return async (dispatch, getState) => {
const {files} = getState().entities;
const fileIdsForPost = files.fileIdsByPostId[post.id];
const fileIdsForPost = files.fileIdsByPostId[postId];
if (!fileIdsForPost) {
await getFilesForPost(post.id)(dispatch, getState);
await getFilesForPost(postId)(dispatch, getState);
}
};
}
export function loadThreadIfNecessary(rootId) {
return async (dispatch, getState) => {
const state = getState();
const {posts} = state.entities.posts;
if (rootId && !posts[rootId]) {
getPostThread(rootId)(dispatch, getState);
}
};
}
@@ -314,11 +326,9 @@ export function unmarkFavorite(channelId) {
};
}
export function refreshChannel(channelId) {
return async (dispatch, getState) => {
dispatch(setChannelRefreshing());
await getPosts(channelId)(dispatch, getState);
dispatch(setChannelRefreshing(false));
export function refreshChannelWithRetry(channelId) {
return (dispatch, getState) => {
getPostsWithRetry(channelId)(dispatch, getState);
};
}
@@ -339,13 +349,6 @@ export function setChannelLoading(loading = true) {
};
}
export function setChannelRefreshing(refreshing = true) {
return {
type: ViewTypes.SET_CHANNEL_REFRESHING,
refreshing
};
}
export function setPostTooltipVisible(visible = true) {
return {
type: ViewTypes.POST_TOOLTIP_VISIBLE,

View File

@@ -2,9 +2,9 @@
// See License.txt for license information.
import {getDirectChannelName} from 'mattermost-redux/utils/channel_utils';
import {createDirectChannel} from 'mattermost-redux/actions/channels';
import {createDirectChannel, createGroupChannel} from 'mattermost-redux/actions/channels';
import {getProfilesByIds, getStatusesByIds} from 'mattermost-redux/actions/users';
import {handleSelectChannel, toggleDMChannel} from 'app/actions/views/channel';
import {handleSelectChannel, toggleDMChannel, toggleGMChannel} from 'app/actions/views/channel';
export function makeDirectChannel(otherUserId) {
return async (dispatch, getState) => {
@@ -12,21 +12,45 @@ export function makeDirectChannel(otherUserId) {
const {currentUserId} = state.entities.users;
const channelName = getDirectChannelName(currentUserId, otherUserId);
const {channels, myMembers} = state.entities.channels;
const channel = Object.values(channels).find((c) => c.name === channelName);
getProfilesByIds([otherUserId])(dispatch, getState);
getStatusesByIds([otherUserId])(dispatch, getState);
let result;
let channel = Object.values(channels).find((c) => c.name === channelName);
if (channel && myMembers[channel.id]) {
result = {data: channel};
toggleDMChannel(otherUserId, 'true')(dispatch, getState);
handleSelectChannel(channel.id)(dispatch, getState);
return true;
}
const created = await createDirectChannel(currentUserId, otherUserId)(dispatch, getState);
if (created.data) {
handleSelectChannel(created.data.id)(dispatch, getState);
} else {
result = await createDirectChannel(currentUserId, otherUserId)(dispatch, getState);
channel = result.data;
}
return created;
if (channel) {
handleSelectChannel(channel.id)(dispatch, getState);
}
return result;
};
}
export function makeGroupChannel(otherUserIds) {
return async (dispatch, getState) => {
const state = getState();
const {currentUserId} = state.entities.users;
getProfilesByIds(otherUserIds)(dispatch, getState);
getStatusesByIds(otherUserIds)(dispatch, getState);
const result = await createGroupChannel([currentUserId, ...otherUserIds])(dispatch, getState);
const channel = result.data;
if (channel) {
toggleGMChannel(channel.id, 'true')(dispatch, getState);
handleSelectChannel(channel.id)(dispatch, getState);
}
return result;
};
}

View File

@@ -10,6 +10,7 @@ import {
} from 'app/actions/views/channel';
import {handleTeamChange, selectFirstAvailableTeam} from 'app/actions/views/select_team';
import {General} from 'mattermost-redux/constants';
import {getClientConfig, getLicenseConfig, setServerVersion} from 'mattermost-redux/actions/general';
import {markChannelAsRead, viewChannel} from 'mattermost-redux/actions/channels';
@@ -47,7 +48,7 @@ export function goToNotification(notification) {
dispatch(setChannelDisplayName(''));
if (teamId) {
handleTeamChange(teams[teamId])(dispatch, getState);
handleTeamChange(teams[teamId], false)(dispatch, getState);
await loadChannelsIfNecessary(teamId)(dispatch, getState);
} else {
await selectFirstAvailableTeam()(dispatch, getState);
@@ -71,6 +72,10 @@ export function setStatusBarHeight(height = 20) {
};
}
export function purgeOfflineStore() {
return {type: General.OFFLINE_STORE_PURGE};
}
export default {
loadConfigAndLicense,
queueNotification,

View File

@@ -0,0 +1,13 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {ViewTypes} from 'app/constants';
export function handleSearchDraftChanged(text) {
return async (dispatch, getState) => {
dispatch({
type: ViewTypes.SEARCH_DRAFT_CHANGED,
text
}, getState);
};
}

View File

@@ -10,7 +10,7 @@ import {NavigationTypes} from 'app/constants';
import {setChannelDisplayName} from './channel';
export function handleTeamChange(team) {
export function handleTeamChange(team, selectChannel = true) {
return async (dispatch, getState) => {
const {currentTeamId} = getState().entities.teams;
if (currentTeamId === team.id) {
@@ -18,14 +18,17 @@ export function handleTeamChange(team) {
}
const state = getState();
const lastChannelId = state.views.team.lastChannelForTeam[team.id] || '';
const actions = [
setChannelDisplayName(''),
{type: TeamTypes.SELECT_TEAM, data: team.id}
];
dispatch(setChannelDisplayName(''), getState);
if (selectChannel) {
const lastChannelId = state.views.team.lastChannelForTeam[team.id] || '';
actions.push({type: ChannelTypes.SELECT_CHANNEL, data: lastChannelId});
}
dispatch(batchActions([
{type: TeamTypes.SELECT_TEAM, data: team.id},
{type: ChannelTypes.SELECT_CHANNEL, data: lastChannelId}
]), getState);
dispatch(batchActions(actions), getState);
};
}

View File

@@ -4,39 +4,73 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Text} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
import CustomPropTypes from 'app/constants/custom_prop_types';
export default class AtMention extends React.PureComponent {
class AtMention extends React.PureComponent {
static propTypes = {
intl: intlShape,
isSearchResult: PropTypes.bool,
mentionName: PropTypes.string.isRequired,
mentionStyle: CustomPropTypes.Style,
navigator: PropTypes.object.isRequired,
onPostPress: PropTypes.func,
textStyle: CustomPropTypes.Style,
theme: PropTypes.object.isRequired,
usersByUsername: PropTypes.object.isRequired
};
constructor(props) {
super(props);
const userDetails = this.getUserDetailsFromMentionName(props);
this.state = {
username: this.getUsernameFromMentionName(props)
username: userDetails.username,
id: userDetails.id
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.mentionName !== this.props.mentionName || nextProps.usersByUsername !== this.props.usersByUsername) {
const userDetails = this.getUserDetailsFromMentionName(nextProps);
this.setState({
username: this.getUsernameFromMentionName(nextProps)
username: userDetails.username,
id: userDetails.id
});
}
}
getUsernameFromMentionName(props) {
goToUserProfile = () => {
const {intl, navigator, theme} = this.props;
navigator.push({
screen: 'UserProfile',
title: intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}),
animated: true,
backButtonTitle: '',
passProps: {
userId: this.state.id
},
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
navBarBackgroundColor: theme.sidebarHeaderBg,
navBarButtonColor: theme.sidebarHeaderTextColor,
screenBackgroundColor: theme.centerChannelBg
}
});
};
getUserDetailsFromMentionName(props) {
let mentionName = props.mentionName;
while (mentionName.length > 0) {
if (props.usersByUsername[mentionName]) {
return props.usersByUsername[mentionName].username;
const user = props.usersByUsername[mentionName];
return {
username: user.username,
id: user.id
};
}
// Repeatedly trim off trailing punctuation in case this is at the end of a sentence
@@ -47,21 +81,27 @@ export default class AtMention extends React.PureComponent {
}
}
return '';
return {
username: ''
};
}
render() {
const {isSearchResult, mentionName, mentionStyle, onPostPress, textStyle} = this.props;
const username = this.state.username;
if (!username) {
return <Text style={this.props.textStyle}>{'@' + this.props.mentionName}</Text>;
return <Text style={textStyle}>{'@' + mentionName}</Text>;
}
const suffix = this.props.mentionName.substring(username.length);
return (
<Text style={this.props.textStyle}>
<Text style={this.props.mentionStyle}>
<Text
style={textStyle}
onPress={isSearchResult ? onPostPress : this.goToUserProfile}
>
<Text style={mentionStyle}>
{'@' + username}
</Text>
{suffix}
@@ -69,3 +109,5 @@ export default class AtMention extends React.PureComponent {
);
}
}
export default injectIntl(AtMention);

View File

@@ -5,10 +5,13 @@ import {connect} from 'react-redux';
import {getUsersByUsername} from 'mattermost-redux/selectors/entities/users';
import {getTheme} from 'app/selectors/preferences';
import AtMention from './at_mention';
function mapStateToProps(state, ownProps) {
return {
theme: getTheme(state),
usersByUsername: getUsersByUsername(state),
...ownProps
};

View File

@@ -12,6 +12,8 @@ import {
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import {sortByUsername} from 'mattermost-redux/utils/user_utils';
import FormattedText from 'app/components/formatted_text';
import ProfilePicture from 'app/components/profile_picture';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
@@ -19,6 +21,7 @@ import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import {RequestStatus} from 'mattermost-redux/constants';
const AT_MENTION_REGEX = /\B(@([^@\r\n\s]*))$/i;
const FROM_REGEX = /\bfrom:\s*(\S*)$/i;
export default class AtMention extends Component {
static propTypes = {
@@ -27,7 +30,8 @@ export default class AtMention extends Component {
currentTeamId: PropTypes.string.isRequired,
cursorPosition: PropTypes.number.isRequired,
defaultChannel: PropTypes.object.isRequired,
autocompleteUsersInCurrentChannel: PropTypes.object.isRequired,
autocompleteUsers: PropTypes.object.isRequired,
isSearch: PropTypes.bool,
postDraft: PropTypes.string,
requestStatus: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
@@ -38,9 +42,10 @@ export default class AtMention extends Component {
};
static defaultProps = {
autocompleteUsersInCurrentChannel: {},
autocompleteUsers: {},
defaultChannel: {},
postDraft: ''
postDraft: '',
isSearch: false
};
constructor(props) {
@@ -58,7 +63,9 @@ export default class AtMention extends Component {
}
componentWillReceiveProps(nextProps) {
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(AT_MENTION_REGEX);
const {isSearch} = nextProps;
const regex = isSearch ? FROM_REGEX : AT_MENTION_REGEX;
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(regex);
if (!match || this.state.mentionComplete) {
this.setState({
@@ -69,8 +76,7 @@ export default class AtMention extends Component {
return;
}
const matchTerm = match[2];
const matchTerm = isSearch ? match[1] : match[2];
if (matchTerm !== this.state.matchTerm) {
this.setState({
matchTerm
@@ -81,30 +87,35 @@ export default class AtMention extends Component {
}
if (nextProps.requestStatus !== RequestStatus.STARTED) {
const membersInChannel = this.filter(nextProps.autocompleteUsersInCurrentChannel.inChannel, matchTerm) || [];
const membersOutOfChannel = this.filter(nextProps.autocompleteUsersInCurrentChannel.outChannel, matchTerm) || [];
const membersInChannel = this.filter(nextProps.autocompleteUsers.inChannel, matchTerm) || [];
const membersOutOfChannel = this.filter(nextProps.autocompleteUsers.outChannel, matchTerm) || [];
let data = {};
if (membersInChannel.length > 0) {
data = Object.assign({}, data, {inChannel: membersInChannel});
}
if (this.checkSpecialMentions(matchTerm)) {
data = Object.assign({}, data, {specialMentions: this.getSpecialMentions()});
}
if (membersOutOfChannel.length > 0) {
data = Object.assign({}, data, {notInChannel: membersOutOfChannel});
if (isSearch) {
data = {members: membersInChannel.concat(membersOutOfChannel).sort(sortByUsername)};
} else {
if (membersInChannel.length > 0) {
data = Object.assign({}, data, {inChannel: membersInChannel});
}
if (this.checkSpecialMentions(matchTerm) && !isSearch) {
data = Object.assign({}, data, {specialMentions: this.getSpecialMentions()});
}
if (membersOutOfChannel.length > 0) {
data = Object.assign({}, data, {notInChannel: membersOutOfChannel});
}
}
this.setState({
active: data.hasOwnProperty('inChannel') || data.hasOwnProperty('specialMentions') || data.hasOwnProperty('notInChannel'),
active: data.hasOwnProperty('inChannel') || data.hasOwnProperty('specialMentions') || data.hasOwnProperty('notInChannel') || data.hasOwnProperty('members'),
dataSource: this.state.dataSource.cloneWithRowsAndSections(data)
});
}
}
filter = (profiles, matchTerm) => {
const {isSearch} = this.props;
return profiles.filter((p) => {
return ((p.id !== this.props.currentUserId) && (
return ((p.id !== this.props.currentUserId || isSearch) && (
p.username.toLowerCase().includes(matchTerm) || p.email.toLowerCase().includes(matchTerm) ||
p.first_name.toLowerCase().includes(matchTerm) || p.last_name.toLowerCase().includes(matchTerm)));
});
@@ -134,14 +145,21 @@ export default class AtMention extends Component {
};
completeMention = (mention) => {
const mentionPart = this.props.postDraft.substring(0, this.props.cursorPosition);
const {cursorPosition, isSearch, onChangeText, postDraft} = this.props;
const mentionPart = postDraft.substring(0, cursorPosition);
let completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `);
if (this.props.postDraft.length > this.props.cursorPosition) {
completedDraft += this.props.postDraft.substring(this.props.cursorPosition);
let completedDraft;
if (isSearch) {
completedDraft = mentionPart.replace(FROM_REGEX, `from: ${mention} `);
} else {
completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `);
}
this.props.onChangeText(completedDraft);
if (postDraft.length > cursorPosition) {
completedDraft += postDraft.substring(cursorPosition);
}
onChangeText(completedDraft);
this.setState({
active: false,
mentionComplete: true
@@ -163,6 +181,10 @@ export default class AtMention extends Component {
specialMentions: {
id: 'suggestion.mention.special',
defaultMessage: 'Special Mentions'
},
members: {
id: 'mobile.suggestion.members',
defaultMessage: 'Members'
}
};
@@ -236,7 +258,7 @@ export default class AtMention extends Component {
};
render() {
const {autocompleteUsersInCurrentChannel, requestStatus} = this.props;
const {autocompleteUsers, requestStatus} = this.props;
if (!this.state.active && (requestStatus !== RequestStatus.STARTED || requestStatus !== RequestStatus.SUCCESS)) {
// If we are not in an active state return null so nothing is rendered
// other components are not blocked.
@@ -246,14 +268,14 @@ export default class AtMention extends Component {
const style = getStyleFromTheme(this.props.theme);
if (
!autocompleteUsersInCurrentChannel.inChannel &&
!autocompleteUsersInCurrentChannel.outChannel &&
!autocompleteUsers.inChannel &&
!autocompleteUsers.outChannel &&
requestStatus === RequestStatus.STARTED
) {
return (
<View style={style.loading}>
<FormattedText
id='analytics.chart.loading": "Loading...'
id='analytics.chart.loading'
defaultMessage='Loading...'
style={style.sectionText}
/>

View File

@@ -15,7 +15,9 @@ function mapStateToProps(state, ownProps) {
const {currentChannelId} = state.entities.channels;
let postDraft;
if (ownProps.rootId.length) {
if (ownProps.isSearch) {
postDraft = state.views.search;
} else if (ownProps.rootId) {
const threadDraft = state.views.thread.drafts[ownProps.rootId];
if (threadDraft) {
postDraft = threadDraft.draft;
@@ -28,18 +30,18 @@ function mapStateToProps(state, ownProps) {
}
return {
...ownProps,
currentUserId: state.entities.users.currentUserId,
currentChannelId,
currentTeamId: state.entities.teams.currentTeamId,
defaultChannel: getDefaultChannel(state),
postDraft,
autocompleteUsersInCurrentChannel: {
autocompleteUsers: {
inChannel: getProfilesInCurrentChannel(state),
outChannel: getProfilesNotInCurrentChannel(state)
},
requestStatus: state.requests.users.autocompleteUsers.status,
theme: getTheme(state)
theme: getTheme(state),
...ownProps
};
}

View File

@@ -17,6 +17,7 @@ import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import {RequestStatus} from 'mattermost-redux/constants';
const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i;
const CHANNEL_SEARCH_REGEX = /\b(?:in|channel):\s*(\S*)$/i;
export default class ChannelMention extends Component {
static propTypes = {
@@ -25,6 +26,7 @@ export default class ChannelMention extends Component {
cursorPosition: PropTypes.number.isRequired,
autocompleteChannels: PropTypes.object.isRequired,
postDraft: PropTypes.string,
isSearch: PropTypes.bool,
requestStatus: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
onChangeText: PropTypes.func.isRequired,
@@ -34,7 +36,8 @@ export default class ChannelMention extends Component {
};
static defaultProps = {
postDraft: ''
postDraft: '',
isSearch: false
};
constructor(props) {
@@ -52,7 +55,9 @@ export default class ChannelMention extends Component {
}
componentWillReceiveProps(nextProps) {
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(CHANNEL_MENTION_REGEX);
const {isSearch} = nextProps;
const regex = isSearch ? CHANNEL_SEARCH_REGEX : CHANNEL_MENTION_REGEX;
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(regex);
// If not match or if user clicked on a channel
if (!match || this.state.mentionComplete) {
@@ -70,7 +75,7 @@ export default class ChannelMention extends Component {
return;
}
const matchTerm = match[2];
const matchTerm = isSearch ? match[1] : match[2];
const myChannels = this.filter(nextProps.autocompleteChannels.myChannels, matchTerm);
const otherChannels = this.filter(nextProps.autocompleteChannels.otherChannels, matchTerm);
@@ -84,7 +89,14 @@ export default class ChannelMention extends Component {
}
// Still matching the same term that didn't return any results
if (match[0].startsWith(`~${this.state.matchTerm}`) && (myChannels.length === 0 && otherChannels.length === 0)) {
let startsWith;
if (isSearch) {
startsWith = match[0].startsWith(`in:${this.state.matchTerm}`) || match[0].startsWith(`channel:${this.state.matchTerm}`);
} else {
startsWith = match[0].startsWith(`~${this.state.matchTerm}`);
}
if (startsWith && (myChannels.length === 0 && otherChannels.length === 0)) {
this.setState({
active: false
});
@@ -123,14 +135,22 @@ export default class ChannelMention extends Component {
};
completeMention = (mention) => {
const mentionPart = this.props.postDraft.substring(0, this.props.cursorPosition);
const {cursorPosition, isSearch, onChangeText, postDraft} = this.props;
const mentionPart = postDraft.substring(0, cursorPosition);
let completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `);
if (this.props.postDraft.length > this.props.cursorPosition) {
completedDraft += this.props.postDraft.substring(this.props.cursorPosition);
let completedDraft;
if (isSearch) {
const channelOrIn = mentionPart.includes('in:') ? 'in:' : 'channel:';
completedDraft = mentionPart.replace(CHANNEL_SEARCH_REGEX, `${channelOrIn} ${mention} `);
} else {
completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `);
}
this.props.onChangeText(completedDraft);
if (postDraft.length > cursorPosition) {
completedDraft += postDraft.substring(cursorPosition);
}
onChangeText(completedDraft);
this.setState({
active: false,
mentionComplete: true,

View File

@@ -16,7 +16,9 @@ function mapStateToProps(state, ownProps) {
const {currentChannelId} = state.entities.channels;
let postDraft;
if (ownProps.rootId.length) {
if (ownProps.isSearch) {
postDraft = state.views.search;
} else if (ownProps.rootId) {
const threadDraft = state.views.thread.drafts[ownProps.rootId];
if (threadDraft) {
postDraft = threadDraft.draft;

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {
FlatList,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import Emoji from 'app/components/emoji';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
const EMOJI_REGEX = /\B(:([^:\r\n\s]*))$/i;
export default class EmojiSuggestion extends Component {
static propTypes = {
cursorPosition: PropTypes.number,
emojis: PropTypes.array.isRequired,
postDraft: PropTypes.string,
theme: PropTypes.object.isRequired,
onChangeText: PropTypes.func.isRequired
};
static defaultProps = {
defaultChannel: {},
postDraft: ''
};
state = {
active: false,
dataSource: []
}
componentWillReceiveProps(nextProps) {
const regex = EMOJI_REGEX;
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(regex);
if (!match || this.state.emojiComplete) {
this.setState({
active: false,
matchTerm: null,
emojiComplete: false
});
return;
}
const matchTerm = match[2];
if (matchTerm !== this.state.matchTerm) {
this.setState({
matchTerm
});
}
let data = [];
if (matchTerm.length) {
data = nextProps.emojis.filter((emoji) => emoji.startsWith(matchTerm.toLowerCase())).sort();
} else {
const initialEmojis = [...nextProps.emojis];
initialEmojis.splice(0, 300);
data = initialEmojis.sort();
}
this.setState({
active: data.length,
dataSource: data
});
}
completeSuggestion = (emoji) => {
const {cursorPosition, onChangeText, postDraft} = this.props;
const emojiPart = postDraft.substring(0, cursorPosition);
let completedDraft = emojiPart.replace(EMOJI_REGEX, `:${emoji}: `);
if (postDraft.length > cursorPosition) {
completedDraft += postDraft.substring(cursorPosition);
}
onChangeText(completedDraft);
this.setState({
active: false,
emojiComplete: true
});
};
keyExtractor = (item) => item;
renderItem = ({item}) => {
const style = getStyleFromTheme(this.props.theme);
return (
<TouchableOpacity
onPress={() => this.completeSuggestion(item)}
style={style.row}
>
<View style={style.emoji}>
<Emoji
emojiName={item}
size={10}
/>
</View>
<Text style={style.emojiName}>{`:${item}:`}</Text>
</TouchableOpacity>
);
};
getItemLayout = ({index}) => ({length: 40, offset: 40 * index, index})
render() {
if (!this.state.active) {
// If we are not in an active state return null so nothing is rendered
// other components are not blocked.
return null;
}
const style = getStyleFromTheme(this.props.theme);
return (
<FlatList
keyboardShouldPersistTaps='always'
style={style.listView}
extraData={this.state}
data={this.state.dataSource}
keyExtractor={this.keyExtractor}
renderItem={this.renderItem}
pageSize={10}
initialListSize={10}
/>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
emoji: {
marginRight: 5
},
emojiName: {
fontSize: 13,
color: theme.centerChannelColor
},
listView: {
flex: 1,
backgroundColor: theme.centerChannelBg
},
row: {
height: 40,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
backgroundColor: theme.centerChannelBg,
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.2),
borderLeftWidth: 1,
borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRightWidth: 1,
borderRightColor: changeOpacity(theme.centerChannelColor, 0.2)
}
});
});

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {createSelector} from 'reselect';
import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
import {getTheme} from 'app/selectors/preferences';
import {EmojiIndicesByAlias} from 'app/utils/emojis';
import EmojiSuggestion from './emoji_suggestion';
const getEmojisByName = createSelector(
getCustomEmojisByName,
(customEmojis) => {
const emoticons = [];
for (const [key] of [...EmojiIndicesByAlias.entries(), ...customEmojis.entries()]) {
emoticons.push(key);
}
return emoticons;
}
);
function mapStateToProps(state, ownProps) {
const {currentChannelId} = state.entities.channels;
const emojis = getEmojisByName(state);
let postDraft;
if (ownProps.rootId) {
const threadDraft = state.views.thread.drafts[ownProps.rootId];
if (threadDraft) {
postDraft = threadDraft.draft;
}
} else if (currentChannelId) {
const channelDraft = state.views.channel.drafts[currentChannelId];
if (channelDraft) {
postDraft = channelDraft.draft;
}
}
return {
emojis,
postDraft,
theme: getTheme(state)
};
}
export default connect(mapStateToProps)(EmojiSuggestion);

View File

@@ -10,6 +10,7 @@ import {
import AtMention from './at_mention';
import ChannelMention from './channel_mention';
import EmojiSuggestion from './emoji_suggestion';
const style = StyleSheet.create({
container: {
@@ -19,13 +20,27 @@ const style = StyleSheet.create({
right: 0,
maxHeight: 200,
overflow: 'hidden'
},
searchContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
maxHeight: 300,
overflow: 'hidden',
zIndex: 5
}
});
export default class Autocomplete extends Component {
static propTypes = {
onChangeText: PropTypes.func.isRequired,
rootId: PropTypes.string
rootId: PropTypes.string,
isSearch: PropTypes.bool
};
static defaultProps = {
isSearch: false
};
state = {
@@ -39,18 +54,21 @@ export default class Autocomplete extends Component {
};
render() {
const container = this.props.isSearch ? style.searchContainer : style.container;
return (
<View>
<View style={style.container}>
<View style={container}>
<AtMention
cursorPosition={this.state.cursorPosition}
onChangeText={this.props.onChangeText}
rootId={this.props.rootId}
{...this.props}
/>
<ChannelMention
cursorPosition={this.state.cursorPosition}
onChangeText={this.props.onChangeText}
rootId={this.props.rootId}
{...this.props}
/>
<EmojiSuggestion
cursorPosition={this.state.cursorPosition}
{...this.props}
/>
</View>
</View>

View File

@@ -33,6 +33,7 @@ export default class Badge extends PureComponent {
super(props);
this.width = 0;
this.mounted = false;
}
componentWillMount() {
@@ -45,12 +46,26 @@ export default class Badge extends PureComponent {
});
}
componentDidMount() {
this.mounted = true;
}
componentWillUnmount() {
this.mounted = false;
}
handlePress = () => {
if (this.props.onPress) {
this.props.onPress();
}
};
setNativeProps = (props) => {
if (this.mounted && this.refs.badgeContainer) {
this.refs.badgeContainer.setNativeProps(props);
}
};
onLayout = (e) => {
let width;
@@ -66,7 +81,7 @@ export default class Badge extends PureComponent {
this.width = width;
const height = Math.max(e.nativeEvent.layout.height, this.props.minHeight);
const borderRadius = height / 2;
this.refs.badgeContainer.setNativeProps({
this.setNativeProps({
style: {
width,
height,
@@ -74,7 +89,7 @@ export default class Badge extends PureComponent {
}
});
setTimeout(() => {
this.refs.badgeContainer.setNativeProps({
this.setNativeProps({
style: {
display: 'flex'
}

View File

@@ -95,6 +95,10 @@ export default class ChannelDrawer extends PureComponent {
InteractionManager.clearInteractionHandle(this.closeLeftHandle);
this.closeLeftHandle = null;
}
if (this.state.openDrawer) {
this.setState({openDrawer: false});
}
};
handleDrawerCloseStart = () => {
@@ -144,7 +148,7 @@ export default class ChannelDrawer extends PureComponent {
this.setState({openDrawer: true});
};
selectChannel = (id) => {
selectChannel = (channel) => {
const {
actions,
currentChannel
@@ -154,15 +158,17 @@ export default class ChannelDrawer extends PureComponent {
handleSelectChannel,
markChannelAsRead,
setChannelLoading,
setChannelDisplayName,
viewChannel
} = actions;
markChannelAsRead(id, currentChannel.id);
markChannelAsRead(channel.id, currentChannel.id);
setChannelLoading();
viewChannel(id, currentChannel.id);
viewChannel(currentChannel.id);
setChannelDisplayName(channel.display_name);
this.closeChannelDrawer();
InteractionManager.runAfterInteractions(() => {
handleSelectChannel(id);
handleSelectChannel(channel.id);
});
};

View File

@@ -25,6 +25,7 @@ import ChannelDrawerItem from 'app/components/channel_drawer/channels_list/chann
class FilteredList extends Component {
static propTypes = {
actions: PropTypes.shape({
getProfilesInTeam: PropTypes.func.isRequired,
makeGroupMessageVisibleIfNecessary: PropTypes.func.isRequired,
searchChannels: PropTypes.func.isRequired,
searchProfiles: PropTypes.func.isRequired
@@ -34,14 +35,16 @@ class FilteredList extends Component {
currentUserId: PropTypes.string,
currentChannel: PropTypes.object,
groupChannels: PropTypes.array,
groupChannelMemberDetails: PropTypes.object,
intl: intlShape.isRequired,
teammateNameDisplay: PropTypes.string,
onSelectChannel: PropTypes.func.isRequired,
otherChannels: PropTypes.array,
profiles: PropTypes.oneOfType(
PropTypes.object,
PropTypes.array
),
profiles: PropTypes.object,
teamProfiles: PropTypes.object,
searchOrder: PropTypes.array.isRequired,
pastDirectMessages: PropTypes.array,
restrictDms: PropTypes.bool.isRequired,
statuses: PropTypes.object,
styles: PropTypes.object.isRequired,
term: PropTypes.string,
@@ -50,7 +53,8 @@ class FilteredList extends Component {
static defaultProps = {
currentTeam: {},
currentChannel: {}
currentChannel: {},
pastDirectMessages: []
};
constructor(props) {
@@ -60,6 +64,12 @@ class FilteredList extends Component {
};
}
componentDidMount() {
if (this.props.restrictDms) {
this.props.actions.getProfilesInTeam(this.props.currentTeam.id);
}
}
shouldComponentUpdate(nextProps, nextState) {
return !deepEqual(this.props, nextProps, {strict: true}) || !deepEqual(this.state, nextState, {strict: true});
}
@@ -75,6 +85,11 @@ class FilteredList extends Component {
clearTimeout(this.searchTimeoutId);
this.searchTimeoutId = setTimeout(() => {
// Android has a fatal error if we send a blank term
if (!term) {
return;
}
searchProfiles(term);
searchChannels(currentTeam.id, term);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
@@ -112,20 +127,158 @@ class FilteredList extends Component {
const text = term.toLowerCase();
return channels.filter((c) => {
return c.display_name.toLowerCase().includes(text);
const fieldsToCheck = ['display_name', 'username', 'email', 'full_name', 'nickname'];
let match = false;
for (const field of fieldsToCheck) {
if (c.hasOwnProperty(field) && c[field].toLowerCase().includes(text)) {
match = true;
break;
}
}
return match;
});
};
getSectionBuilders = () => ({
unreads: {
builder: this.buildUnreadChannelsForSearch,
id: 'mobile.channel_list.unreads',
defaultMessage: 'UNREADS'
},
channels: {
builder: this.buildChannelsForSearch,
id: 'sidebar.channels',
defaultMessage: 'CHANNELS'
},
dms: {
builder: this.buildCurrentDMSForSearch,
id: 'sidebar.direct',
defaultMessage: 'DIRECT MESSAGES'
},
members: {
builder: this.buildMembersForSearch,
id: 'mobile.channel_list.members',
defaultMessage: 'MEMBERS'
},
nonmembers: {
builder: this.buildOtherMembersForSearch,
id: 'mobile.channel_list.not_member',
defaultMessage: 'NOT A MEMBER'
}
});
buildUnreadChannelsForSearch = (props, term) => {
const {unreadChannels} = props.channels;
return this.filterChannels(unreadChannels, term);
}
buildCurrentDMSForSearch = (props, term) => {
const {channels, teammateNameDisplay, profiles, statuses, pastDirectMessages, groupChannelMemberDetails} = props;
const {favoriteChannels} = channels;
const favoriteDms = favoriteChannels.filter((c) => {
return c.type === General.DM_CHANNEL;
});
const directChannelUsers = [];
let groupChannels = [];
channels.directAndGroupChannels.forEach((c) => {
if (c.type === General.DM_CHANNEL) {
if (profiles.hasOwnProperty(c.teammate_id)) {
directChannelUsers.push(profiles[c.teammate_id]);
}
} else {
groupChannels.push(c);
}
});
const pastDirectMessageUsers = pastDirectMessages.map((p) => profiles[p]).filter((p) => typeof p !== 'undefined');
const dms = [...directChannelUsers, ...pastDirectMessageUsers].map((u) => {
const displayName = displayUsername(u, teammateNameDisplay);
return {
id: u.id,
status: statuses[u.id],
display_name: displayName,
username: u.username,
email: u.email,
name: displayName,
type: General.DM_CHANNEL,
fake: true,
nickname: u.nickname,
fullname: `${u.first_name} ${u.last_name}`
};
});
groupChannels = groupChannels.map((channel) => {
return {
...channel,
...groupChannelMemberDetails[channel.id]
};
});
return this.filterChannels([...favoriteDms, ...dms, ...groupChannels], term).sort(sortChannelsByDisplayName.bind(null, props.intl.locale));
}
buildMembersForSearch = (props, term) => {
const {channels, currentUserId, teammateNameDisplay, profiles, teamProfiles, statuses, pastDirectMessages, restrictDms} = props;
const {favoriteChannels, unreadChannels} = channels;
const favoriteAndUnreadDms = [...favoriteChannels, ...unreadChannels].filter((c) => {
return c.type === General.DM_CHANNEL;
});
const directAndGroupChannelMembers = [...channels.directAndGroupChannels, ...favoriteAndUnreadDms].filter((c) => c.type === General.DM_CHANNEL).map((c) => c.teammate_id);
const profilesToUse = restrictDms ? teamProfiles : profiles;
const userNotInDirectOrGroupChannels = Object.values(profilesToUse).filter((u) => directAndGroupChannelMembers.indexOf(u.id) === -1 && pastDirectMessages.indexOf(u.id) === -1 && u.id !== currentUserId);
const members = userNotInDirectOrGroupChannels.map((u) => {
const displayName = displayUsername(u, teammateNameDisplay);
return {
id: u.id,
status: statuses[u.id],
display_name: displayName,
username: u.username,
email: u.email,
name: displayName,
type: General.DM_CHANNEL,
fake: true,
nickname: u.nickname,
fullname: `${u.first_name} ${u.last_name}`
};
});
const fakeDms = this.filterChannels([...members], term);
return [...fakeDms].sort(sortChannelsByDisplayName.bind(null, props.intl.locale));
}
buildChannelsForSearch = (props, term) => {
const data = [];
const {groupChannels, otherChannels, styles} = props;
const {
unreadChannels,
favoriteChannels,
publicChannels,
privateChannels
} = props.channels;
const favorites = favoriteChannels.filter((c) => {
return c.type !== General.DM_CHANNEL && c.type !== General.GM_CHANNEL;
});
return this.filterChannels([...favorites, ...publicChannels, ...privateChannels], term).
sort(sortChannelsByDisplayName.bind(null, props.intl.locale));
}
buildOtherMembersForSearch = (props, term) => {
const {otherChannels} = props;
const notMemberOf = otherChannels.map((o) => {
return {
...o,
@@ -133,66 +286,30 @@ class FilteredList extends Component {
};
});
const favorites = favoriteChannels.filter((c) => {
return c.type !== General.DM_CHANNEL && c.type !== General.GM_CHANNEL;
});
return this.filterChannels(notMemberOf, term);
}
const unreads = this.filterChannels(unreadChannels, term);
const channels = this.filterChannels([...favorites, ...publicChannels, ...privateChannels], term).
sort(sortChannelsByDisplayName.bind(null, props.intl.locale));
buildSectionsForSearch = (props, term) => {
const items = [];
const {searchOrder, styles} = props;
const sectionBuilders = this.getSectionBuilders();
const others = this.filterChannels(notMemberOf, term);
const groups = this.filterChannels(groupChannels, term);
const fakeDms = this.filterChannels(this.buildFakeDms(props), term);
const directMessages = [...groups, ...fakeDms].sort(sortChannelsByDisplayName.bind(null, props.intl.locale));
let previousDataLength = 0;
for (const section of searchOrder) {
if (sectionBuilders.hasOwnProperty(section)) {
const sectionBuilder = sectionBuilders[section];
const {builder, defaultMessage, id} = sectionBuilder;
const data = builder(props, term);
if (unreads.length) {
data.push(
this.renderTitle(styles, 'mobile.channel_list.unreads', 'UNREADS', null, false, true),
...unreads
);
if (data.length) {
const title = this.renderTitle(styles, id, defaultMessage, null, previousDataLength > 0, true);
items.push(title, ...data);
previousDataLength = data.length;
}
}
}
if (channels.length) {
data.push(
this.renderTitle(styles, 'sidebar.channels', 'CHANNELS', null, unreads.length > 0, true),
...channels
);
}
if (others.length) {
data.push(
this.renderTitle(styles, 'mobile.channel_list.not_member', 'NOT A MEMBER', null, channels.length > 0, true),
...others
);
}
if (directMessages.length) {
data.push(
this.renderTitle(styles, 'sidebar.direct', 'DIRECT MESSAGES', null, others.length > 0, true),
...directMessages
);
}
return data;
};
buildFakeDms = (props) => {
const {currentUserId, teammateNameDisplay, profiles, statuses} = props;
const users = Object.values(profiles).filter((p) => p.id !== currentUserId);
return users.map((u) => {
const displayName = displayUsername(u, teammateNameDisplay);
return {
id: u.id,
status: statuses[u.id],
display_name: displayName,
name: displayName,
type: General.DM_CHANNEL,
fake: true
};
});
return items;
};
buildData = (props, term) => {
@@ -200,7 +317,7 @@ class FilteredList extends Component {
return null;
}
return this.buildChannelsForSearch(props, term);
return this.buildSectionsForSearch(props, term);
};
renderSectionAction = (styles, action) => {

View File

@@ -3,35 +3,116 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {createSelector} from 'reselect';
import {searchChannels} from 'mattermost-redux/actions/channels';
import {searchProfiles} from 'mattermost-redux/actions/users';
import {getProfilesInTeam, searchProfiles} from 'mattermost-redux/actions/users';
import {makeGroupMessageVisibleIfNecessary} from 'mattermost-redux/actions/preferences';
import {General} from 'mattermost-redux/constants';
import {getGroupChannels, getOtherChannels} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getProfilesInCurrentTeam, getUsers, getUserStatuses} from 'mattermost-redux/selectors/entities/users';
import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId, getProfilesInCurrentTeam, getUsers, getUserIdsInChannels, getUserStatuses} from 'mattermost-redux/selectors/entities/users';
import {getDirectShowPreferences, getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import Config from 'assets/config';
import FilteredList from './filtered_list';
const DEFAULT_SEARCH_ORDER = ['unreads', 'dms', 'channels', 'members', 'nonmembers'];
const pastDirectMessages = createSelector(
getDirectShowPreferences,
(directChannelsFromPreferences) => directChannelsFromPreferences.filter((d) => d.value === 'false').map((d) => d.name)
);
const getTeamProfiles = createSelector(
getProfilesInCurrentTeam,
(members) => {
return members.reduce((memberProfiles, member) => {
memberProfiles[member.id] = member;
return memberProfiles;
}, {});
}
);
// Fill an object for each group channel with concatenated strings for username, email, fullname, and nickname
function getGroupDetails(currentUserId, userIdsInChannels, profiles, groupChannels) {
return groupChannels.reduce((groupMemberDetails, channel) => {
if (!userIdsInChannels.hasOwnProperty(channel.id)) {
return groupMemberDetails;
}
const members = Array.from(userIdsInChannels[channel.id]).reduce((memberDetails, member) => {
if (member === currentUserId) {
return memberDetails;
}
const details = {...memberDetails};
const profile = profiles[member];
details.username.push(profile.username);
if (profile.email) {
details.email.push(profile.email);
}
if (profile.nickname) {
details.nickname.push(profile.nickname);
}
if (profile.fullname) {
details.fullname.push(`${profile.first_name} ${profile.last_name}`);
}
return details;
}, {
email: [],
fullname: [],
nickname: [],
username: []
});
groupMemberDetails[channel.id] = {
email: members.email.join(','),
fullname: members.fullname.join(','),
nickname: members.nickname.join(','),
username: members.username.join(',')
};
return groupMemberDetails;
}, {});
}
const getGroupChannelMemberDetails = createSelector(
getCurrentUserId,
getUserIdsInChannels,
getUsers,
getGroupChannels,
getGroupDetails
);
function mapStateToProps(state, ownProps) {
const {currentUserId} = state.entities.users;
let profiles;
if (getConfig(state).RestrictDirectMessage === General.RESTRICT_DIRECT_MESSAGE_ANY) {
profiles = getUsers(state);
} else {
profiles = getProfilesInCurrentTeam(state);
const profiles = getUsers(state);
let teamProfiles = {};
const restrictDms = getConfig(state).RestrictDirectMessage !== General.RESTRICT_DIRECT_MESSAGE_ANY;
if (restrictDms) {
teamProfiles = getTeamProfiles(state);
}
const searchOrder = Config.DrawerSearchOrder ? Config.DrawerSearchOrder : DEFAULT_SEARCH_ORDER;
return {
currentUserId,
otherChannels: getOtherChannels(state),
groupChannels: getGroupChannels(state),
groupChannelMemberDetails: getGroupChannelMemberDetails(state),
profiles,
teamProfiles,
teammateNameDisplay: getTeammateNameDisplaySetting(state),
statuses: getUserStatuses(state),
searchOrder,
pastDirectMessages: pastDirectMessages(state),
restrictDms,
...ownProps
};
}
@@ -39,6 +120,7 @@ function mapStateToProps(state, ownProps) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
getProfilesInTeam,
makeGroupMessageVisibleIfNecessary,
searchChannels,
searchProfiles

View File

@@ -52,8 +52,7 @@ class ChannelsList extends Component {
term: ''
};
MaterialIcon.getImageSource('close', 20, this.props.theme.sidebarHeaderTextColor).
then((source) => {
MaterialIcon.getImageSource('close', 20, this.props.theme.sidebarHeaderTextColor).then((source) => {
this.closeButton = source;
});
}
@@ -62,7 +61,7 @@ class ChannelsList extends Component {
if (channel.fake) {
this.props.onJoinChannel(channel);
} else {
this.props.onSelectChannel(channel.id);
this.props.onSelectChannel(channel);
}
this.refs.search_bar.cancel();
@@ -166,6 +165,7 @@ class ChannelsList extends Component {
tintColorSearch={changeOpacity(theme.sidebarHeaderTextColor, 0.8)}
tintColorDelete={changeOpacity(theme.sidebarHeaderTextColor, 0.5)}
titleCancelColor={theme.sidebarHeaderTextColor}
selectionColor={changeOpacity(theme.sidebarHeaderTextColor, 0.5)}
onSearchButtonPress={this.onSearch}
onCancelButtonPress={this.cancelSearch}
onChangeText={this.onSearch}
@@ -294,10 +294,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
paddingHorizontal: 10,
...Platform.select({
android: {
height: 46
height: 46,
marginRight: 6
},
ios: {
height: 44
height: 44,
marginRight: 8
}
})
},

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {General} from 'mattermost-redux/constants';
@@ -9,8 +8,6 @@ import {getCurrentUserId, getCurrentUserRoles} from 'mattermost-redux/selectors/
import {showCreateOption} from 'mattermost-redux/utils/channel_utils';
import {isAdmin, isSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {setChannelDisplayName} from 'app/actions/views/channel';
import List from './list';
function mapStateToProps(state, ownProps) {
@@ -23,12 +20,4 @@ function mapStateToProps(state, ownProps) {
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
setChannelDisplayName
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(List);
export default connect(mapStateToProps, null)(List);

View File

@@ -24,9 +24,6 @@ import UnreadIndicator from 'app/components/channel_drawer/channels_list/unread_
class List extends Component {
static propTypes = {
actions: PropTypes.shape({
setChannelDisplayName: PropTypes.func.isRequired
}).isRequired,
canCreatePrivateChannels: PropTypes.bool.isRequired,
channels: PropTypes.object.isRequired,
channelMembers: PropTypes.object,
@@ -50,8 +47,7 @@ class List extends Component {
showAbove: false
};
MaterialIcon.getImageSource('close', 20, this.props.theme.sidebarHeaderTextColor).
then((source) => {
MaterialIcon.getImageSource('close', 20, this.props.theme.sidebarHeaderTextColor).then((source) => {
this.closeButton = source;
});
}
@@ -95,7 +91,6 @@ class List extends Component {
};
onSelectChannel = (channel) => {
this.props.actions.setChannelDisplayName(channel.display_name);
this.props.onSelectChannel(channel);
};
@@ -243,7 +238,7 @@ class List extends Component {
navigator.showModal({
screen: 'MoreDirectMessages',
title: intl.formatMessage({id: 'more_direct_channels.title', defaultMessage: 'Direct Messages'}),
title: intl.formatMessage({id: 'mobile.more_dms.title', defaultMessage: 'New Conversation'}),
animationType: 'slide-up',
animated: true,
backButtonTitle: '',
@@ -278,7 +273,6 @@ class List extends Component {
screenBackgroundColor: theme.centerChannelBg
},
passProps: {
channelType: General.PRIVATE_CHANNEL,
closeButton: this.closeButton
}
});

View File

@@ -16,89 +16,111 @@ import {General} from 'mattermost-redux/constants';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
function channelIcon(props) {
const {isActive, hasUnread, isInfo, membersCount, size, status, theme, type} = props;
const style = getStyleSheet(theme);
export default class ChannelIcon extends React.PureComponent {
static propTypes = {
isActive: PropTypes.bool,
isInfo: PropTypes.bool,
hasUnread: PropTypes.bool,
membersCount: PropTypes.number,
size: PropTypes.number,
status: PropTypes.string,
theme: PropTypes.object.isRequired,
type: PropTypes.string.isRequired
};
let activeIcon;
let unreadIcon;
let activeGroupBox;
let unreadGroupBox;
let activeGroup;
let unreadGroup;
static defaultProps = {
isActive: false,
isInfo: false,
hasUnread: false,
size: 12
};
if (hasUnread) {
unreadIcon = style.iconUnread;
unreadGroupBox = style.groupBoxUnread;
unreadGroup = style.groupUnread;
}
render() {
const {isActive, hasUnread, isInfo, membersCount, size, status, theme, type} = this.props;
const style = getStyleSheet(theme);
if (isActive) {
activeIcon = style.iconActive;
activeGroupBox = style.groupBoxActive;
activeGroup = style.groupActive;
}
let activeIcon;
let unreadIcon;
let activeGroupBox;
let unreadGroupBox;
let activeGroup;
let unreadGroup;
let offlineColor = changeOpacity(theme.sidebarText, 0.5);
if (isInfo) {
activeIcon = style.iconInfo;
activeGroupBox = style.groupBoxInfo;
activeGroup = style.groupInfo;
}
if (hasUnread) {
unreadIcon = style.iconUnread;
unreadGroupBox = style.groupBoxUnread;
unreadGroup = style.groupUnread;
}
if (type === General.OPEN_CHANNEL) {
return (
<Icon
name='globe'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size}]}
/>
);
} else if (type === General.PRIVATE_CHANNEL) {
return (
<Icon
name='lock'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size}]}
/>
);
} else if (type === General.GM_CHANNEL) {
return (
<View style={style.groupContainer}>
if (isActive) {
activeIcon = style.iconActive;
activeGroupBox = style.groupBoxActive;
activeGroup = style.groupActive;
}
if (isInfo) {
activeIcon = style.iconInfo;
activeGroupBox = style.groupBoxInfo;
activeGroup = style.groupInfo;
offlineColor = changeOpacity(theme.centerChannelColor, 0.5);
}
let icon;
if (type === General.OPEN_CHANNEL) {
icon = (
<Icon
name='globe'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size}]}
/>
);
} else if (type === General.PRIVATE_CHANNEL) {
icon = (
<Icon
name='lock'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size}]}
/>
);
} else if (type === General.GM_CHANNEL) {
icon = (
<View style={[style.groupBox, unreadGroupBox, activeGroupBox, {width: size, height: size}]}>
<Text style={[style.group, unreadGroup, activeGroup, {fontSize: (size - 6)}]}>
{membersCount}
</Text>
</View>
</View>
);
}
switch (status) {
case General.ONLINE:
);
} else if (type === General.DM_CHANNEL) {
if (status === General.ONLINE) {
icon = (
<OnlineStatus
width={size}
height={size}
color={theme.onlineIndicator}
/>
);
} else if (status === General.AWAY) {
icon = (
<AwayStatus
width={size}
height={size}
color={theme.awayIndicator}
/>
);
} else {
icon = (
<OfflineStatus
width={size}
height={size}
color={offlineColor}
/>
);
}
}
return (
<View style={style.statusIcon}>
<OnlineStatus
width={size}
height={size}
color={theme.onlineIndicator}
/>
</View>
);
case General.AWAY:
return (
<View style={style.statusIcon}>
<AwayStatus
width={size}
height={size}
color={theme.awayIndicator}
/>
</View>
);
default:
return (
<View style={style.statusIcon}>
<OfflineStatus
width={size}
height={size}
color={changeOpacity(theme.sidebarText, 0.5)}
/>
<View style={[style.container, {width: size, height: size}]}>
{icon}
</View>
);
}
@@ -106,9 +128,12 @@ function channelIcon(props) {
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
marginRight: 12,
alignItems: 'center'
},
icon: {
color: changeOpacity(theme.sidebarText, 0.4),
paddingRight: 12
color: changeOpacity(theme.sidebarText, 0.4)
},
iconActive: {
color: theme.sidebarTextActiveColor
@@ -119,12 +144,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
iconInfo: {
color: theme.centerChannelColor
},
statusIcon: {
paddingRight: 12
},
groupContainer: {
paddingRight: 12
},
groupBox: {
alignSelf: 'flex-start',
alignItems: 'center',
@@ -157,23 +176,3 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
}
});
});
channelIcon.propTypes = {
isActive: PropTypes.bool,
isInfo: PropTypes.bool,
hasUnread: PropTypes.bool,
membersCount: PropTypes.number,
size: PropTypes.number,
status: PropTypes.string,
theme: PropTypes.object.isRequired,
type: PropTypes.string.isRequired
};
channelIcon.defaultProps = {
isActive: false,
isInfo: false,
hasUnread: false,
size: 12
};
export default channelIcon;

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {TouchableOpacity} from 'react-native';
import CustomPropTypes from 'app/constants/custom_prop_types';
export default class ConditionalTouchable extends React.PureComponent {
static propTypes = {
touchable: PropTypes.bool,
children: CustomPropTypes.Children.isRequired
};
render() {
const {touchable, children, ...otherProps} = this.props;
if (touchable) {
return (
<TouchableOpacity {...otherProps}>
{children}
</TouchableOpacity>
);
}
return React.Children.only(children);
}
}

View File

@@ -1,148 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
function createTouchableComponent(children, action) {
return (
<TouchableOpacity onPress={action}>
{children}
</TouchableOpacity>
);
}
function ChannelListRow(props) {
const {id, displayName, purpose, onPress, theme} = props;
const style = getStyleFromTheme(theme);
let purposeComponent;
if (purpose) {
purposeComponent = (
<View style={{flexShrink: 1, flexDirection: 'column', flexWrap: 'wrap'}}>
<Text
style={style.purpose}
ellipsizeMode='tail'
numberOfLines={1}
>
{purpose}
</Text>
</View>
);
}
const RowComponent = (
<View style={style.container}>
<View style={style.titleContainer}>
{props.selectable &&
<TouchableWithoutFeedback onPress={props.onRowSelect}>
<View style={style.selectorContainer}>
<View style={[style.selector, (props.selected && style.selectorFilled)]}>
{props.selected &&
<Icon
name='check'
size={16}
color='#fff'
/>
}
</View>
</View>
</TouchableWithoutFeedback>
}
<Icon
name='globe'
style={style.icon}
/>
<View style={style.textContainer}>
<View style={{flexGrow: 1, flexDirection: 'column'}}>
<Text style={style.displayName}>
{displayName}
</Text>
</View>
</View>
</View>
{purposeComponent}
</View>
);
if (typeof onPress === 'function') {
return createTouchableComponent(RowComponent, () => onPress(id));
}
return RowComponent;
}
ChannelListRow.propTypes = {
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
purpose: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
onPress: PropTypes.func,
selectable: PropTypes.bool,
onRowSelect: PropTypes.func,
selected: PropTypes.bool
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
flexDirection: 'column',
height: 65,
paddingHorizontal: 15,
justifyContent: 'center',
backgroundColor: theme.centerChannelBg
},
titleContainer: {
alignItems: 'center',
flexDirection: 'row'
},
displayName: {
fontSize: 16,
color: theme.centerChannelColor
},
icon: {
fontSize: 16,
color: theme.centerChannelColor
},
textContainer: {
flex: 1,
flexDirection: 'row',
marginLeft: 5
},
purpose: {
marginTop: 7,
fontSize: 13,
color: changeOpacity(theme.centerChannelColor, 0.5)
},
selector: {
height: 28,
width: 28,
borderRadius: 14,
borderWidth: 1,
borderColor: '#888',
alignItems: 'center',
justifyContent: 'center'
},
selectorContainer: {
height: 50,
paddingRight: 15,
alignItems: 'center',
justifyContent: 'center'
},
selectorFilled: {
backgroundColor: '#378FD2',
borderWidth: 0
}
});
});
export default ChannelListRow;

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
Text,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import CustomListRow from 'app/components/custom_list/custom_list_row';
export default class ChannelListRow extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
channel: PropTypes.object.isRequired,
...CustomListRow.propTypes
};
onPress = () => {
this.props.onPress(this.props.id);
};
render() {
const style = getStyleFromTheme(this.props.theme);
let purpose;
if (this.props.channel.purpose) {
purpose = (
<Text
style={style.purpose}
ellipsizeMode='tail'
numberOfLines={1}
>
{this.props.channel.purpose}
</Text>
);
}
return (
<CustomListRow
id={this.props.id}
theme={this.props.theme}
onPress={this.props.onPress ? this.onPress : null}
enabled={this.props.enabled}
selectable={this.props.selectable}
selected={this.props.selected}
>
<View style={style.container}>
<View style={style.titleContainer}>
<Icon
name='globe'
style={style.icon}
/>
<Text style={style.displayName}>
{this.props.channel.display_name}
</Text>
</View>
{purpose}
</View>
</CustomListRow>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
titleContainer: {
alignItems: 'center',
flexDirection: 'row'
},
displayName: {
fontSize: 16,
color: theme.centerChannelColor,
marginLeft: 5
},
icon: {
fontSize: 16,
color: theme.centerChannelColor
},
container: {
flex: 1,
flexDirection: 'column'
},
purpose: {
marginTop: 7,
fontSize: 13,
color: changeOpacity(theme.centerChannelColor, 0.5)
}
});
});

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from 'app/selectors/preferences';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import ChannelListRow from './channel_list_row';
function makeMapStateToProps() {
const getChannel = makeGetChannel();
return (state, ownProps) => {
return {
theme: getTheme(state),
channel: getChannel(state, ownProps),
...ownProps
};
};
}
export default connect(makeMapStateToProps)(ChannelListRow);

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {
StyleSheet,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import ConditionalTouchable from 'app/components/conditional_touchable';
import CustomPropTypes from 'app/constants/custom_prop_types';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
export default class CustomListRow extends React.PureComponent {
static propTypes = {
theme: PropTypes.object.isRequired,
onPress: PropTypes.func,
enabled: PropTypes.bool,
selectable: PropTypes.bool,
selected: PropTypes.bool,
children: CustomPropTypes.Children
};
static defaultProps = {
enabled: true
};
render() {
const style = getStyleFromTheme(this.props.theme);
return (
<ConditionalTouchable
touchable={Boolean(this.props.enabled && this.props.onPress)}
onPress={this.props.onPress}
>
<View style={style.container}>
{this.props.selectable &&
<View style={style.selectorContainer}>
<View style={[style.selector, (this.props.selected && style.selectorFilled), (!this.props.enabled && style.selectorDisabled)]}>
{this.props.selected &&
<Icon
name='check'
size={16}
color='#fff'
/>
}
</View>
</View>
}
{this.props.children}
</View>
</ConditionalTouchable>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
flexDirection: 'row',
height: 65,
paddingHorizontal: 15,
alignItems: 'center',
backgroundColor: theme.centerChannelBg
},
displayName: {
fontSize: 15,
color: theme.centerChannelColor
},
selector: {
height: 28,
width: 28,
borderRadius: 14,
borderWidth: 1,
borderColor: '#888',
alignItems: 'center',
justifyContent: 'center'
},
selectorContainer: {
height: 50,
paddingRight: 15,
alignItems: 'center',
justifyContent: 'center'
},
selectorDisabled: {
backgroundColor: '#888'
},
selectorFilled: {
backgroundColor: '#378FD2',
borderWidth: 0
}
});
});

View File

@@ -13,16 +13,15 @@ export default class CustomList extends PureComponent {
data: PropTypes.array.isRequired,
theme: PropTypes.object.isRequired,
searching: PropTypes.bool,
onRowPress: PropTypes.func,
onListEndReached: PropTypes.func,
onListEndReachedThreshold: PropTypes.number,
teammateNameDisplay: PropTypes.string,
loading: PropTypes.bool,
loadingText: PropTypes.object,
listPageSize: PropTypes.number,
listInitialSize: PropTypes.number,
listScrollRenderAheadDistance: PropTypes.number,
showSections: PropTypes.bool,
onRowPress: PropTypes.func,
selectable: PropTypes.bool,
onRowSelect: PropTypes.func,
renderRow: PropTypes.func.isRequired,
@@ -37,9 +36,8 @@ export default class CustomList extends PureComponent {
listPageSize: 10,
listInitialSize: 10,
listScrollRenderAheadDistance: 200,
selectable: false,
loadingText: null,
onRowSelect: () => true,
selectable: false,
createSections: () => true,
showSections: true,
showNoResults: true
@@ -114,17 +112,32 @@ export default class CustomList extends PureComponent {
);
};
renderRow = (rowData, sectionId, rowId) => {
return this.props.renderRow(
rowData,
sectionId,
rowId,
this.props.teammateNameDisplay,
this.props.theme,
this.props.selectable,
this.props.onRowPress,
this.handleRowSelect
);
renderRow = (item, sectionId, rowId) => {
const props = {
id: item.id,
item,
selected: item.selected,
selectable: this.props.selectable,
onPress: this.props.onRowPress
};
if ('disableSelect' in item) {
props.enabled = !item.disableSelect;
}
if (this.props.onRowSelect) {
props.onPress = this.handleRowSelect.bind(this, sectionId, rowId);
} else {
props.onPress = this.props.onRowPress;
}
// Allow passing in a component like UserListRow or ChannelListRow
if (this.props.renderRow.prototype.isReactComponent) {
const RowComponent = this.props.renderRow;
return <RowComponent {...props}/>;
}
return this.props.renderRow(props);
};
renderSeparator = (sectionId, rowId) => {

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import ProfilePicture from 'app/components/profile_picture';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
function createTouchableComponent(children, action) {
return (
<TouchableOpacity onPress={action}>
{children}
</TouchableOpacity>
);
}
function MemberListRow(props) {
const {id, displayName, username, onPress, theme, user, disableSelect} = props;
const style = getStyleFromTheme(theme);
const RowComponent = (
<View style={style.container}>
{props.selectable &&
<TouchableWithoutFeedback onPress={disableSelect ? () => false : props.onRowSelect}>
<View style={style.selectorContainer}>
<View style={[style.selector, (props.selected && style.selectorFilled), (disableSelect && style.selectorDisabled)]}>
{props.selected &&
<Icon
name='check'
size={16}
color='#fff'
/>
}
</View>
</View>
</TouchableWithoutFeedback>
}
<ProfilePicture
user={user}
size={32}
/>
<View style={style.textContainer}>
<View style={{flexDirection: 'column'}}>
<Text style={style.displayName}>
{displayName}
</Text>
</View>
<View style={{flexShrink: 1, flexDirection: 'column', flexWrap: 'wrap'}}>
<Text
style={style.username}
ellipsizeMode='tail'
numberOfLines={1}
>
{`(@${username})`}
</Text>
</View>
</View>
</View>
);
if (typeof onPress === 'function') {
return createTouchableComponent(RowComponent, () => onPress(id));
} else if (typeof props.onRowSelect === 'function') {
return createTouchableComponent(RowComponent, disableSelect ? () => false : props.onRowSelect);
}
return RowComponent;
}
MemberListRow.propTypes = {
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
pictureURL: PropTypes.string,
username: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
onPress: PropTypes.func,
selectable: PropTypes.bool,
onRowSelect: PropTypes.func,
selected: PropTypes.bool,
disableSelect: PropTypes.bool
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
flexDirection: 'row',
height: 65,
paddingHorizontal: 15,
alignItems: 'center',
backgroundColor: theme.centerChannelBg
},
displayName: {
fontSize: 15,
color: theme.centerChannelColor
},
icon: {
fontSize: 20,
color: theme.centerChannelColor
},
textContainer: {
flexDirection: 'row',
marginLeft: 5
},
username: {
marginLeft: 5,
fontSize: 15,
color: changeOpacity(theme.centerChannelColor, 0.5)
},
selector: {
height: 28,
width: 28,
borderRadius: 14,
borderWidth: 1,
borderColor: '#888',
alignItems: 'center',
justifyContent: 'center'
},
selectorContainer: {
height: 50,
paddingRight: 15,
alignItems: 'center',
justifyContent: 'center'
},
selectorDisabled: {
backgroundColor: '#888'
},
selectorFilled: {
backgroundColor: '#378FD2',
borderWidth: 0
}
});
});
export default MemberListRow;

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from 'app/selectors/preferences';
import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import UserListRow from './user_list_row';
function mapStateToProps(state, ownProps) {
return {
theme: getTheme(state),
user: getUser(state, ownProps.id),
teammateNameDisplay: getTeammateNameDisplaySetting(state),
...ownProps
};
}
export default connect(mapStateToProps)(UserListRow);

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
Text,
View
} from 'react-native';
import ProfilePicture from 'app/components/profile_picture';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import CustomListRow from 'app/components/custom_list/custom_list_row';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
export default class UserListRow extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
user: PropTypes.object.isRequired,
teammateNameDisplay: PropTypes.string.isRequired,
...CustomListRow.propTypes
};
onPress = () => {
this.props.onPress(this.props.id);
};
render() {
const style = getStyleFromTheme(this.props.theme);
return (
<CustomListRow
id={this.props.id}
theme={this.props.theme}
onPress={this.props.onPress ? this.onPress : null}
enabled={this.props.enabled}
selectable={this.props.selectable}
selected={this.props.selected}
>
<ProfilePicture
user={this.props.user}
size={32}
/>
<View style={style.textContainer}>
<View>
<Text style={style.displayName}>
{displayUsername(this.props.user, this.props.teammateNameDisplay)}
</Text>
</View>
<View>
<Text
style={style.username}
ellipsizeMode='tail'
numberOfLines={1}
>
{`(@${this.props.user.username})`}
</Text>
</View>
</View>
</CustomListRow>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
flexDirection: 'row',
height: 65,
paddingHorizontal: 15,
alignItems: 'center',
backgroundColor: theme.centerChannelBg
},
displayName: {
fontSize: 15,
color: theme.centerChannelColor
},
icon: {
fontSize: 20,
color: theme.centerChannelColor
},
textContainer: {
flexDirection: 'row',
marginLeft: 5
},
username: {
marginLeft: 5,
fontSize: 15,
color: changeOpacity(theme.centerChannelColor, 0.5)
},
selector: {
height: 28,
width: 28,
borderRadius: 14,
borderWidth: 1,
borderColor: '#888',
alignItems: 'center',
justifyContent: 'center'
},
selectorContainer: {
height: 50,
paddingRight: 15,
alignItems: 'center',
justifyContent: 'center'
},
selectorDisabled: {
backgroundColor: '#888'
},
selectorFilled: {
backgroundColor: '#378FD2',
borderWidth: 0
}
});
});

View File

@@ -0,0 +1,311 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {
Platform,
SectionList,
StyleSheet,
Text,
View
} from 'react-native';
import Loading from 'app/components/loading';
import FormattedText from 'app/components/formatted_text';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
export default class CustomSectionList extends React.PureComponent {
static propTypes = {
/*
* The current theme.
*/
theme: PropTypes.object.isRequired,
/*
* An array of items to be rendered.
*/
items: PropTypes.array.isRequired,
/*
* A function or React element used to render the items in the list.
*/
renderItem: PropTypes.func,
/*
* Whether or not to render "No results" when the list contains no items.
*/
showNoResults: PropTypes.bool,
/*
* A function to get a section identifier for each item in the list. Items sharing a section identifier will be grouped together.
*/
sectionKeyExtractor: PropTypes.func.isRequired,
/*
* A comparison function used when sorting items in the list.
*/
compareItems: PropTypes.func.isRequired,
/*
* A function to get a unique key for each item in the list. If not provided, the id field of the item will be used as the key.
*/
keyExtractor: PropTypes.func,
/*
* Any extra data needed to render the list. If this value changes, all items of a list will be re-rendered.
*/
extraData: PropTypes.object,
/*
* A function called when an item in the list is pressed. Receives the item that was pressed as an argument.
*/
onRowPress: PropTypes.func,
/*
* A function called when the end of the list is reached. This can be triggered before this list end is reached
* by changing onListEndReachedThreshold.
*/
onListEndReached: PropTypes.func,
/*
* How soon before the end of the list onListEndReached should be called.
*/
onListEndReachedThreshold: PropTypes.number,
/*
* Whether or not to display the loading text.
*/
loading: PropTypes.bool,
/*
* The text displayed when loading is set to true.
*/
loadingText: PropTypes.object,
/*
* How many items to render when the list is first rendered.
*/
initialNumToRender: PropTypes.number
};
static defaultKeyExtractor = (item) => {
return item.id;
};
static defaultProps = {
showNoResults: true,
keyExtractor: CustomSectionList.defaultKeyExtractor,
onListEndReached: () => true,
onListEndReachedThreshold: 50,
loadingText: null,
initialNumToRender: 10
};
constructor(props) {
super(props);
this.state = {
sections: this.extractSections(props.items)
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.items !== this.props.items) {
this.setState({
sections: this.extractSections(nextProps.items)
});
}
}
extractSections = (items) => {
const sections = {};
const sectionKeys = [];
for (const item of items) {
const sectionKey = this.props.sectionKeyExtractor(item);
if (!sections[sectionKey]) {
sections[sectionKey] = [];
sectionKeys.push(sectionKey);
}
sections[sectionKey].push(item);
}
sectionKeys.sort();
return sectionKeys.map((sectionKey) => {
return {
key: sectionKey,
data: sections[sectionKey].sort(this.props.compareItems)
};
});
}
renderSectionHeader = ({section}) => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
return (
<View style={style.sectionWrapper}>
<View style={style.sectionContainer}>
<Text style={style.sectionText}>{section.key}</Text>
</View>
</View>
);
};
renderItem = ({item}) => {
const props = {
id: item.id,
item,
onPress: this.props.onRowPress
};
// Allow passing in a component like UserListRow or ChannelListRow
if (this.props.renderItem.prototype.isReactElement) {
const RowComponent = this.props.renderItem;
return <RowComponent {...props}/>;
}
return this.props.renderItem(props);
};
renderItemSeparator = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
return (
<View style={style.separator}/>
);
};
renderFooter = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (!this.props.loading || !this.props.loadingText) {
return null;
}
if (this.props.items.length === 0) {
return null;
}
return (
<View style={style.loading}>
<FormattedText
{...this.props.loadingText}
style={style.loadingText}
/>
</View>
);
};
renderEmptyList = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (this.props.loading) {
return (
<View
style={style.searching}
>
<Loading/>
</View>
);
}
if (this.props.showNoResults) {
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
}
return null;
}
render() {
const style = getStyleFromTheme(this.props.theme);
return (
<SectionList
style={style.listView}
initialNumToRender={this.props.initialNumToRender}
renderSectionHeader={this.renderSectionHeader}
ItemSeparatorComponent={this.renderItemSeparator}
ListFooterComponent={this.renderFooter}
ListEmptyComponent={this.renderEmptyList}
onEndReached={this.props.onListEndReached}
onEndReachedThreshold={this.props.onListEndReachedThreshold}
extraData={this.props.extraData}
sections={this.state.sections}
keyExtractor={this.props.keyExtractor}
renderItem={this.renderItem}
keyboardShouldPersistTaps='handled'
/>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
listView: {
flex: 1,
backgroundColor: theme.centerChannelBg,
...Platform.select({
android: {
marginBottom: 20
}
})
},
loading: {
height: 70,
backgroundColor: theme.centerChannelBg,
alignItems: 'center',
justifyContent: 'center'
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6)
},
searching: {
backgroundColor: theme.centerChannelBg,
height: '100%',
position: 'absolute',
width: '100%'
},
sectionContainer: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.07),
paddingLeft: 10,
paddingVertical: 2
},
sectionWrapper: {
backgroundColor: theme.centerChannelBg
},
sectionText: {
fontWeight: '600',
color: theme.centerChannelColor
},
separator: {
height: 1,
flex: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1)
},
noResultContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
},
noResultText: {
fontSize: 26,
color: changeOpacity(theme.centerChannelColor, 0.5)
}
});
});

View File

@@ -15,13 +15,15 @@ export default class Emoji extends React.PureComponent {
customEmojis: PropTypes.object,
emojiName: PropTypes.string.isRequired,
literal: PropTypes.string,
padding: PropTypes.number,
size: PropTypes.number.isRequired,
textStyle: CustomPropTypes.Style
}
static defaultProps = {
customEmojis: new Map(),
literal: ''
literal: '',
padding: 10
}
render() {
@@ -29,6 +31,7 @@ export default class Emoji extends React.PureComponent {
customEmojis,
emojiName,
literal,
padding,
size,
textStyle
} = this.props;
@@ -48,7 +51,7 @@ export default class Emoji extends React.PureComponent {
return (
<Image
style={{width: size, height: size, padding: 10}}
style={{width: size, height: size, padding}}
source={{uri: imageUrl}}
/>
);

View File

@@ -46,7 +46,7 @@ export default class FileAttachmentImage extends PureComponent {
static defaultProps = {
fadeInOnLoad: false,
imageHeight: 100,
imageSize: IMAGE_SIZE.Thumbnail,
imageSize: IMAGE_SIZE.Preview,
imageWidth: 100,
loading: false,
loadingBackgroundColor: '#fff',
@@ -114,12 +114,24 @@ export default class FileAttachmentImage extends PureComponent {
}
};
calculateNeededWidth = (height, width, newHeight) => {
const ratio = width / height;
let newWidth = newHeight * ratio;
if (newWidth < newHeight) {
newWidth = newHeight;
}
return newWidth;
}
render() {
const {
fetchCache,
file,
imageHeight,
imageWidth,
imageSize,
loadingBackgroundColor,
resizeMethod,
resizeMode,
@@ -147,11 +159,20 @@ export default class FileAttachmentImage extends PureComponent {
};
const opacity = isInFetchCache ? 1 : this.state.opacity;
let height = imageHeight;
let width = imageWidth;
let imageStyle = {height, width};
if (imageSize === IMAGE_SIZE.Preview) {
height = 100;
width = this.calculateNeededWidth(file.height, file.width, height);
imageStyle = {height, width, position: 'absolute', top: 0, left: 0};
}
return (
<View style={[style.fileImageWrapper, {backgroundColor: wrapperBackgroundColor, height: wrapperHeight, width: wrapperWidth}]}>
<View style={[style.fileImageWrapper, {backgroundColor: wrapperBackgroundColor, height: wrapperHeight, width: wrapperWidth, overflow: 'hidden'}]}>
<AnimatedView style={{height: imageHeight, width: imageWidth, backgroundColor: wrapperBackgroundColor, opacity}}>
<Image
style={{height: imageHeight, width: imageWidth}}
style={imageStyle}
source={source}
resizeMode={resizeMode}
resizeMethod={resizeMethod}

View File

@@ -4,6 +4,7 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {
Keyboard,
View,
TouchableOpacity
} from 'react-native';
@@ -16,39 +17,41 @@ export default class FileAttachmentList extends Component {
static propTypes = {
actions: PropTypes.object.isRequired,
fetchCache: PropTypes.object.isRequired,
fileIds: PropTypes.array.isRequired,
files: PropTypes.array.isRequired,
hideOptionsContext: PropTypes.func.isRequired,
isFailed: PropTypes.bool,
navigator: PropTypes.object,
onLongPress: PropTypes.func,
onPress: PropTypes.func,
post: PropTypes.object.isRequired,
postId: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
toggleSelected: PropTypes.func.isRequired,
filesForPostRequest: PropTypes.object.isRequired
};
componentDidMount() {
const {post} = this.props;
this.props.actions.loadFilesForPostIfNecessary(post);
const {postId} = this.props;
this.props.actions.loadFilesForPostIfNecessary(postId);
}
componentDidUpdate() {
const {files, filesForPostRequest, post} = this.props;
const {fileIds, files, filesForPostRequest, postId} = this.props;
// Fixes an issue where files weren't loading with optimistic post
if (!files.length && post.file_ids.length > 0 && filesForPostRequest.status !== RequestStatus.STARTED) {
this.props.actions.loadFilesForPostIfNecessary(post);
if (!files.length && fileIds.length > 0 && filesForPostRequest.status !== RequestStatus.STARTED) {
this.props.actions.loadFilesForPostIfNecessary(postId);
}
}
goToImagePreview = (post, fileId) => {
goToImagePreview = (postId, fileId) => {
this.props.navigator.showModal({
screen: 'ImagePreview',
title: '',
animationType: 'none',
passProps: {
fileId,
post
postId
},
navigatorStyle: {
navBarHidden: true,
@@ -67,15 +70,24 @@ export default class FileAttachmentList extends Component {
handlePreviewPress = (file) => {
this.props.hideOptionsContext();
preventDoubleTap(this.goToImagePreview, this, this.props.post, file.id);
Keyboard.dismiss();
preventDoubleTap(this.goToImagePreview, this, this.props.postId, file.id);
};
handlePressIn = () => {
this.props.toggleSelected(true);
};
handlePressOut = () => {
this.props.toggleSelected(false);
};
render() {
const {files, post} = this.props;
const {fileIds, files, isFailed} = this.props;
let fileAttachments;
if (!files.length && post.file_ids.length > 0) {
fileAttachments = post.file_ids.map((id) => (
if (!files.length && fileIds.length > 0) {
fileAttachments = fileIds.map((id) => (
<FileAttachment
key={id}
addFileToFetchCache={this.props.actions.addFileToFetchCache}
@@ -89,8 +101,8 @@ export default class FileAttachmentList extends Component {
<TouchableOpacity
key={file.id}
onLongPress={this.props.onLongPress}
onPressIn={() => this.props.toggleSelected(true)}
onPressOut={() => this.props.toggleSelected(false)}
onPressIn={this.handlePressIn}
onPressOut={this.handlePressOut}
>
<FileAttachment
addFileToFetchCache={this.props.actions.addFileToFetchCache}
@@ -105,7 +117,7 @@ export default class FileAttachmentList extends Component {
}
return (
<View style={[{flex: 1}, (post.failed && {opacity: 0.5})]}>
<View style={[{flex: 1}, (isFailed && {opacity: 0.5})]}>
{fileAttachments}
</View>
);

View File

@@ -17,7 +17,7 @@ function makeMapStateToProps() {
return {
...ownProps,
fetchCache: state.views.fetchCache,
files: getFilesForPost(state, ownProps.post),
files: getFilesForPost(state, ownProps.postId),
theme: getTheme(state),
filesForPostRequest: state.requests.files.getFilesForPost
};

View File

@@ -12,7 +12,6 @@ import {
View
} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import {RequestStatus} from 'mattermost-redux/constants';
import FileAttachmentImage from 'app/components/file_attachment_list/file_attachment_image';
import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachment_icon';
@@ -35,7 +34,7 @@ export default class FileUploadPreview extends PureComponent {
inputHeight: PropTypes.number.isRequired,
rootId: PropTypes.string,
theme: PropTypes.object.isRequired,
uploadFileRequestStatus: PropTypes.string.isRequired
filesUploadingForCurrentChannel: PropTypes.bool.isRequired
};
handleRetryFileUpload = (file) => {
@@ -102,7 +101,7 @@ export default class FileUploadPreview extends PureComponent {
}
render() {
if (this.props.channelIsLoading || (!this.props.files.length && this.props.uploadFileRequestStatus !== RequestStatus.STARTED)) {
if (this.props.channelIsLoading || (!this.props.files.length && !this.props.filesUploadingForCurrentChannel)) {
return null;
}

View File

@@ -3,6 +3,7 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {createSelector} from 'reselect';
import {handleRemoveFile, retryFileUpload} from 'app/actions/views/file_upload';
import {addFileToFetchCache} from 'app/actions/views/file_preview';
@@ -10,13 +11,26 @@ import {getTheme} from 'app/selectors/preferences';
import FileUploadPreview from './file_upload_preview';
const checkForFileUploadingInChannel = createSelector(
(state, channelId, rootId) => {
if (rootId) {
return state.views.thread.drafts[rootId];
}
return state.views.channel.drafts[channelId];
},
(draft) => {
return draft.files.some((f) => f.loading);
}
);
function mapStateToProps(state, ownProps) {
return {
...ownProps,
channelIsLoading: state.views.channel.loading,
createPostRequestStatus: state.requests.posts.createPost.status,
fetchCache: state.views.fetchCache,
uploadFileRequestStatus: state.requests.files.uploadFiles.status,
filesUploadingForCurrentChannel: checkForFileUploadingInChannel(state, ownProps.channelId, ownProps.rootId),
theme: getTheme(state)
};
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {Component} from 'react';
import {Component, createElement, isValidElement} from 'react';
import PropTypes from 'prop-types';
import {Text} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
@@ -27,11 +27,67 @@ class FormattedText extends Component {
...props
} = this.props;
return (
<Text {...props}>
{intl.formatMessage({id, defaultMessage}, values)}
</Text>
);
let tokenDelimiter;
let tokenizedValues;
let elements;
const hasValues = values && Object.keys(values).length > 0;
if (hasValues) {
// Creates a token with a random UID that should not be guessable or
// conflict with other parts of the `message` string.
const uid = Math.floor(Math.random() * 0x10000000000).toString(16);
const generateToken = (() => {
let counter = 0;
return () => {
const elementId = `ELEMENT-${uid}-${counter += 1}`;
return elementId;
};
})();
// Splitting with a delimiter to support IE8. When using a regex
// with a capture group IE8 does not include the capture group in
// the resulting array.
tokenDelimiter = `@__${uid}__@`;
tokenizedValues = {};
elements = {};
// Iterates over the `props` to keep track of any React Element
// values so they can be represented by the `token` as a placeholder
// when the `message` is formatted. This allows the formatted
// message to then be broken-up into parts with references to the
// React Elements inserted back in.
Object.keys(values).forEach((name) => {
const value = values[name];
if (isValidElement(value)) {
const token = generateToken();
tokenizedValues[name] = tokenDelimiter + token + tokenDelimiter;
elements[token] = value;
} else {
tokenizedValues[name] = value;
}
});
}
const descriptor = {id, defaultMessage};
const formattedMessage = intl.formatMessage(descriptor, tokenizedValues || values);
const hasElements = elements && Object.keys(elements).length > 0;
let nodes;
if (hasElements) {
// Split the message into parts so the React Element values captured
// above can be inserted back into the rendered message. This
// approach allows messages to render with React Elements while
// keeping React's virtual diffing working properly.
nodes = formattedMessage.
split(tokenDelimiter).
filter((part) => Boolean(part)).
map((part) => elements[part] || part);
} else {
nodes = [formattedMessage];
}
return createElement(Text, props, ...nodes);
}
}

View File

@@ -3,7 +3,7 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {FlatList, RefreshControl, ScrollView, StyleSheet, View} from 'react-native';
import {FlatList, Platform, RefreshControl, ScrollView, StyleSheet, View} from 'react-native';
import VirtualList from './virtual_list';
@@ -129,11 +129,21 @@ const styles = StyleSheet.create({
container: {
flex: 1
},
vertical: {
transform: [{scaleY: -1}]
},
horizontal: {
transform: [{scaleX: -1}]
}
vertical: Platform.select({
android: {
scaleY: -1
},
ios: {
transform: [{scaleY: -1}]
}
}),
horizontal: Platform.select({
android: {
scaleX: -1
},
ios: {
transform: [{scaleX: -1}]
}
})
});

View File

@@ -3,7 +3,7 @@
import {Parser} from 'commonmark';
import Renderer from 'commonmark-react-renderer';
import React from 'react';
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Platform,
@@ -24,14 +24,17 @@ import MarkdownLink from './markdown_link';
import MarkdownList from './markdown_list';
import MarkdownListItem from './markdown_list_item';
export default class Markdown extends React.PureComponent {
export default class Markdown extends PureComponent {
static propTypes = {
baseTextStyle: CustomPropTypes.Style,
textStyles: PropTypes.object,
blockStyles: PropTypes.object,
emojiSizes: PropTypes.object,
value: PropTypes.string.isRequired,
onLongPress: PropTypes.func
isSearchResult: PropTypes.bool,
navigator: PropTypes.object.isRequired,
onLongPress: PropTypes.func,
onPostPress: PropTypes.func,
textStyles: PropTypes.object,
value: PropTypes.string.isRequired
};
static defaultProps = {
@@ -133,7 +136,10 @@ export default class Markdown extends React.PureComponent {
<AtMention
mentionStyle={this.props.textStyles.mention}
textStyle={this.computeTextStyle(this.props.baseTextStyle, context)}
isSearchResult={this.props.isSearchResult}
mentionName={mentionName}
onPostPress={this.props.onPostPress}
navigator={this.props.navigator}
/>
);
}

View File

@@ -210,7 +210,8 @@ const styles = StyleSheet.create({
flex: 1,
height: HEIGHT,
flexDirection: 'row',
paddingHorizontal: 12,
paddingLeft: 12,
paddingRight: 5,
backgroundColor: 'red'
},
message: {
@@ -220,13 +221,15 @@ const styles = StyleSheet.create({
flex: 1
},
actionButton: {
alignItems: 'center',
borderWidth: 1,
borderColor: '#FFFFFF'
},
actionContainer: {
alignItems: 'center',
alignItems: 'flex-end',
height: 24,
justifyContent: 'center',
paddingRight: 10,
width: 60
}
});

View File

@@ -1,14 +1,18 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {PureComponent} from 'react';
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {TouchableHighlight, View} from 'react-native';
import RNBottomSheet from 'react-native-bottom-sheet';
export default class OptionsContext extends PureComponent {
static propTypes = {
actions: PropTypes.array,
cancelText: PropTypes.string
cancelText: PropTypes.string,
children: PropTypes.node.isRequired,
onPress: PropTypes.func.isRequired,
toggleSelected: PropTypes.func.isRequired
};
static defaultProps = {
@@ -34,7 +38,28 @@ export default class OptionsContext extends PureComponent {
}
};
handleHideUnderlay = () => {
this.props.toggleSelected(false);
};
handleShowUnderlay = () => {
this.props.toggleSelected(true);
};
render() {
return null;
return (
<TouchableHighlight
onHideUnderlay={this.handleHideUnderlay}
onLongPress={this.show}
onPress={this.props.onPress}
onShowUnderlay={this.handleShowUnderlay}
underlayColor='transparent'
style={{flex: 1, flexDirection: 'row'}}
>
<View style={{flex: 1}}>
{this.props.children}
</View>
</TouchableHighlight>
);
}
}

View File

@@ -17,13 +17,21 @@ export default class OptionsContext extends PureComponent {
actions: []
};
hide() {
this.refs.toolTip.hideMenu();
}
handleHide = () => {
this.props.toggleSelected(false);
};
show() {
handleShow = () => {
this.props.toggleSelected(true);
};
hide = () => {
this.refs.toolTip.hideMenu();
};
show = () => {
this.refs.toolTip.showMenu();
}
};
render() {
return (
@@ -34,8 +42,8 @@ export default class OptionsContext extends PureComponent {
longPress={true}
onPress={this.props.onPress}
underlayColor='transparent'
onShow={() => this.props.toggleSelected(true)}
onHide={() => this.props.toggleSelected(false)}
onShow={this.handleShow}
onHide={this.handleHide}
>
{this.props.children}
</ToolTip>

View File

@@ -4,12 +4,9 @@
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {createPost, deletePost, flagPost, removePost, unflagPost} from 'mattermost-redux/actions/posts';
import {getMyPreferences, getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import {makeGetCommentCountForPost} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentUserId, getCurrentUserRoles, getUser} from 'mattermost-redux/selectors/entities/users';
import {isPostFlagged} from 'mattermost-redux/utils/post_utils';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
import {createPost, deletePost, removePost} from 'mattermost-redux/actions/posts';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentUserId, getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users';
import {setPostTooltipVisible} from 'app/actions/views/channel';
import {getTheme} from 'app/selectors/preferences';
@@ -17,29 +14,23 @@ import {getTheme} from 'app/selectors/preferences';
import Post from './post';
function makeMapStateToProps() {
const getCommentCountForPost = makeGetCommentCountForPost();
return function mapStateToProps(state, ownProps) {
const commentedOnUser = ownProps.commentedOnPost ? getUser(state, ownProps.commentedOnPost.user_id) : null;
const user = getUser(state, ownProps.post.user_id);
const myPreferences = getMyPreferences(state);
const teammateNameDisplay = getTeammateNameDisplaySetting(state);
const post = getPost(state, ownProps.post.id);
const {config, license} = state.entities.general;
const roles = getCurrentUserId(state) ? getCurrentUserRoles(state) : '';
const {tooltipVisible} = state.views.channel;
return {
...ownProps,
post,
config,
commentCount: getCommentCountForPost(state, ownProps),
commentedOnDisplayName: displayUsername(commentedOnUser, teammateNameDisplay),
currentUserId: getCurrentUserId(state),
displayName: displayUsername(user, teammateNameDisplay),
isFlagged: isPostFlagged(ownProps.post.id, myPreferences),
highlight: ownProps.post.highlight,
license,
roles,
theme: getTheme(state),
tooltipVisible,
user
tooltipVisible
};
};
}
@@ -49,10 +40,8 @@ function mapDispatchToProps(dispatch) {
actions: bindActionCreators({
createPost,
deletePost,
flagPost,
removePost,
setPostTooltipVisible,
unflagPost
setPostTooltipVisible
}, dispatch)
};
}

View File

@@ -5,73 +5,58 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Alert,
Image,
Platform,
StyleSheet,
Text,
TouchableHighlight,
TouchableOpacity,
View,
ViewPropTypes
} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
import Icon from 'react-native-vector-icons/Ionicons';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import PostBody from 'app/components/post_body';
import PostHeader from 'app/components/post_header';
import PostProfilePicture from 'app/components/post_profile_picture';
import {NavigationTypes} from 'app/constants';
import FileAttachmentList from 'app/components/file_attachment_list';
import FormattedText from 'app/components/formatted_text';
import FormattedTime from 'app/components/formatted_time';
import MattermostIcon from 'app/components/mattermost_icon';
import Markdown from 'app/components/markdown';
import OptionsContext from 'app/components/options_context';
import ProfilePicture from 'app/components/profile_picture';
import ReplyIcon from 'app/components/reply_icon';
import SlackAttachments from 'app/components/slack_attachments';
import {emptyFunction} from 'app/utils/general';
import {preventDoubleTap} from 'app/utils/tap';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from 'app/utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import webhookIcon from 'assets/images/icons/webhook.jpg';
import {Posts} from 'mattermost-redux/constants';
import DelayedAction from 'mattermost-redux/utils/delayed_action';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {canDeletePost, canEditPost, isPostEphemeral, isPostPendingOrFailed, isSystemMessage} from 'mattermost-redux/utils/post_utils';
import {isAdmin, isSystemAdmin} from 'mattermost-redux/utils/user_utils';
const BOT_NAME = 'BOT';
class Post extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
createPost: PropTypes.func.isRequired,
deletePost: PropTypes.func.isRequired,
removePost: PropTypes.func.isRequired,
setPostTooltipVisible: PropTypes.func.isRequired
}).isRequired,
config: PropTypes.object.isRequired,
commentCount: PropTypes.number.isRequired,
currentUserId: PropTypes.string.isRequired,
highlight: PropTypes.bool,
intl: intlShape.isRequired,
style: ViewPropTypes.style,
post: PropTypes.object.isRequired,
user: PropTypes.object,
displayName: PropTypes.string,
renderReplies: PropTypes.bool,
isFirstReply: PropTypes.bool,
isFlagged: PropTypes.bool,
isLastReply: PropTypes.bool,
commentedOnDisplayName: PropTypes.string,
isSearchResult: PropTypes.bool,
commentedOnPost: PropTypes.object,
license: PropTypes.object.isRequired,
navigator: PropTypes.object,
roles: PropTypes.string,
shouldRenderReplyButton: PropTypes.bool,
tooltipVisible: PropTypes.bool,
theme: PropTypes.object.isRequired,
onPress: PropTypes.func,
actions: PropTypes.shape({
createPost: PropTypes.func.isRequired,
deletePost: PropTypes.func.isRequired,
flagPost: PropTypes.func.isRequired,
removePost: PropTypes.func.isRequired,
setPostTooltipVisible: PropTypes.func.isRequired,
unflagPost: PropTypes.func.isRequired
}).isRequired
onReply: PropTypes.func
};
static defaultProps = {
isSearchResult: false
};
constructor(props) {
@@ -97,15 +82,15 @@ class Post extends PureComponent {
this.editDisableAction.cancel();
}
goToUserProfile = (userId) => {
const {intl, navigator, theme} = this.props;
goToUserProfile = () => {
const {intl, navigator, post, theme} = this.props;
navigator.push({
screen: 'UserProfile',
title: intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}),
animated: true,
backButtonTitle: '',
passProps: {
userId
userId: post.user_id
},
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
@@ -224,10 +209,13 @@ class Post extends PureComponent {
}
};
hideOptionsContext = () => {
if (Platform.OS === 'ios') {
this.refs.tooltip.hide();
handleReply = () => {
const {post, onReply, tooltipVisible} = this.props;
if (!tooltipVisible && onReply) {
return preventDoubleTap(onReply, null, post);
}
return this.handlePress();
};
onRemovePost = (post) => {
@@ -235,246 +223,40 @@ class Post extends PureComponent {
removePost(post);
};
showOptionsContext = () => {
if (Platform.OS === 'ios') {
return this.refs.tooltip.show();
}
renderReplyBar = () => {
const {
commentedOnPost,
isFirstReply,
isLastReply,
post,
renderReplies,
theme
} = this.props;
return this.refs.bottomSheet.show();
};
renderCommentedOnMessage = (style) => {
if (!this.props.renderReplies || !this.props.commentedOnPost) {
return null;
}
const displayName = this.props.commentedOnDisplayName;
let name;
if (displayName) {
name = displayName;
} else {
name = (
<FormattedText
id='channel_loader.someone'
defaultMessage='Someone'
/>
);
}
let apostrophe;
if (displayName && displayName.slice(-1) === 's') {
apostrophe = '\'';
} else {
apostrophe = '\'s';
}
return (
<FormattedText
id='post_body.commentedOn'
defaultMessage='Commented on {name}{apostrophe} message: '
values={{
name,
apostrophe
}}
style={style.commentedOn}
/>
);
};
renderReplyBar = (style) => {
if (!this.props.renderReplies || !this.props.post.root_id) {
if (!renderReplies || !post.root_id) {
return null;
}
const style = getStyleSheet(theme);
const replyBarStyle = [style.replyBar];
if (this.props.isFirstReply || this.props.commentedOnPost) {
if (isFirstReply || commentedOnPost) {
replyBarStyle.push(style.replyBarFirst);
}
if (this.props.isLastReply) {
if (isLastReply) {
replyBarStyle.push(style.replyBarLast);
}
return <View style={replyBarStyle}/>;
};
renderFileAttachments() {
const {navigator, post} = this.props;
const fileIds = post.file_ids || [];
let attachments;
if (fileIds.length > 0) {
attachments = (
<FileAttachmentList
hideOptionsContext={this.hideOptionsContext}
onLongPress={this.showOptionsContext}
onPress={this.handlePress}
post={post}
toggleSelected={this.toggleSelected}
navigator={navigator}
/>
);
}
return attachments;
}
renderSlackAttachments = (baseStyle, blockStyles, textStyles) => {
const {post, theme} = this.props;
if (post.props) {
const {attachments} = post.props;
if (attachments && attachments.length) {
return (
<SlackAttachments
attachments={attachments}
baseTextStyle={baseStyle}
blockStyles={blockStyles}
textStyles={textStyles}
theme={theme}
/>
);
}
}
return null;
};
renderMessage = (style, messageStyle, blockStyles, textStyles, replyBar = false) => {
const {formatMessage} = this.props.intl;
const {isFlagged, post, theme} = this.props;
const {flagPost, unflagPost} = this.props.actions;
const actions = [];
// we should check for the user roles and permissions
if (!isPostPendingOrFailed(post)) {
if (isFlagged) {
actions.push({
text: formatMessage({id: 'post_info.mobile.unflag', defaultMessage: 'Unflag'}),
onPress: () => unflagPost(post.id)
});
} else {
actions.push({
text: formatMessage({id: 'post_info.mobile.flag', defaultMessage: 'Flag'}),
onPress: () => flagPost(post.id)
});
}
if (this.state.canEdit) {
actions.push({text: formatMessage({id: 'post_info.edit', defaultMessage: 'Edit'}), onPress: () => this.handlePostEdit()});
}
if (this.state.canDelete && post.state !== Posts.POST_DELETED) {
actions.push({text: formatMessage({id: 'post_info.del', defaultMessage: 'Delete'}), onPress: () => this.handlePostDelete()});
}
}
let messageContainer;
let message;
if (post.state === Posts.POST_DELETED) {
message = (
<FormattedText
style={messageStyle}
id='post_body.deleted'
defaultMessage='(message deleted)'
/>
);
} else if (this.props.post.message.length) {
message = (
<View style={{flexDirection: 'row'}}>
<View style={[{flex: 1}, (isPostPendingOrFailed(post) && style.pendingPost)]}>
<Markdown
baseTextStyle={messageStyle}
textStyles={textStyles}
blockStyles={blockStyles}
value={post.message}
onLongPress={this.showOptionsContext}
/>
</View>
</View>
);
}
if (Platform.OS === 'ios') {
messageContainer = (
<View style={style.messageContainerWithReplyBar}>
{replyBar && this.renderReplyBar(style)}
<View style={{flex: 1, flexDirection: 'row'}}>
<View style={{flex: 1}}>
<OptionsContext
actions={actions}
ref='tooltip'
onPress={this.handlePress}
toggleSelected={this.toggleSelected}
>
{message}
{this.renderSlackAttachments(messageStyle, blockStyles, textStyles)}
{this.renderFileAttachments()}
</OptionsContext>
</View>
{post.failed &&
<TouchableOpacity
onPress={this.handleFailedPostPress}
style={{justifyContent: 'center', marginLeft: 12}}
>
<Icon
name='ios-information-circle-outline'
size={26}
color={theme.errorTextColor}
/>
</TouchableOpacity>
}
</View>
</View>
);
} else {
messageContainer = (
<View style={style.messageContainerWithReplyBar}>
{replyBar && this.renderReplyBar(style)}
<TouchableHighlight
onHideUnderlay={() => this.toggleSelected(false)}
onLongPress={this.showOptionsContext}
onPress={this.handlePress}
onShowUnderlay={() => this.toggleSelected(true)}
underlayColor='transparent'
style={{flex: 1, flexDirection: 'row'}}
>
<View style={{flexDirection: 'row', flex: 1}}>
<View style={{flex: 1}}>
{message}
<OptionsContext
ref='bottomSheet'
actions={actions}
cancelText={formatMessage({id: 'channel_modal.cancel', defaultMessage: 'Cancel'})}
/>
{this.renderSlackAttachments(messageStyle, blockStyles, textStyles)}
{this.renderFileAttachments()}
</View>
{post.failed &&
<TouchableOpacity
onPress={this.handleFailedPostPress}
style={{justifyContent: 'center', marginLeft: 12}}
>
<Icon
name='ios-information-circle-outline'
size={26}
color={theme.errorTextColor}
/>
</TouchableOpacity>
}
</View>
</TouchableHighlight>
</View>
);
}
return messageContainer;
};
viewUserProfile = () => {
preventDoubleTap(this.goToUserProfile, null, this.props.user.id);
const {isSearchResult} = this.props;
if (!isSearchResult) {
preventDoubleTap(this.goToUserProfile, this);
}
};
toggleSelected = (selected) => {
@@ -484,176 +266,58 @@ class Post extends PureComponent {
render() {
const {
commentCount,
config,
commentedOnPost,
highlight,
isLastReply,
isSearchResult,
post,
renderReplies,
shouldRenderReplyButton,
theme
} = this.props;
const style = getStyleSheet(theme);
const PROFILE_PICTURE_SIZE = 32;
const selected = this.state && this.state.selected ? style.selected : null;
const highlighted = highlight ? style.highlight : null;
let profilePicture;
let displayName;
let messageStyle;
if (isSystemMessage(post)) {
profilePicture = (
<View style={style.profilePicture}>
<MattermostIcon
color={theme.centerChannelColor}
height={PROFILE_PICTURE_SIZE}
width={PROFILE_PICTURE_SIZE}
return (
<View style={[style.container, this.props.style, highlighted, selected]}>
<View style={[style.profilePictureContainer, (isPostPendingOrFailed(post) && style.pendingPost)]}>
<PostProfilePicture
onViewUserProfile={this.viewUserProfile}
postId={post.id}
/>
</View>
);
displayName = (
<FormattedText
id='post_info.system'
defaultMessage='System'
style={style.displayName}
/>
);
messageStyle = [style.message, style.systemMessage];
} else if (post.props && post.props.from_webhook) {
if (config.EnablePostIconOverride === 'true') {
const icon = post.props.override_icon_url ? {uri: post.props.override_icon_url} : webhookIcon;
profilePicture = (
<View style={style.profilePicture}>
<Image
source={icon}
style={{
height: PROFILE_PICTURE_SIZE,
width: PROFILE_PICTURE_SIZE,
borderRadius: PROFILE_PICTURE_SIZE / 2
}}
<View style={style.messageContainerWithReplyBar}>
{!commentedOnPost && this.renderReplyBar()}
<View style={[style.rightColumn, (commentedOnPost && isLastReply && style.rightColumnPadding)]}>
<PostHeader
postId={post.id}
commentedOnUserId={commentedOnPost && commentedOnPost.user_id}
createAt={post.create_at}
isSearchResult={isSearchResult}
shouldRenderReplyButton={shouldRenderReplyButton}
onPress={this.handleReply}
onViewUserProfile={this.viewUserProfile}
renderReplies={renderReplies}
theme={theme}
/>
<PostBody
canDelete={this.state.canDelete}
canEdit={this.state.canEdit}
isSearchResult={isSearchResult}
navigator={this.props.navigator}
onFailedPostPress={this.handleFailedPostPress}
onPostDelete={this.handlePostDelete}
onPostEdit={this.handlePostEdit}
onPress={this.handlePress}
postId={post.id}
renderReplyBar={commentedOnPost ? this.renderReplyBar : emptyFunction}
toggleSelected={this.toggleSelected}
/>
</View>
);
} else {
profilePicture = (
<ProfilePicture
user={this.props.user}
size={PROFILE_PICTURE_SIZE}
/>
);
}
let name = this.props.displayName;
if (post.props.override_username && config.EnablePostUsernameOverride === 'true') {
name = post.props.override_username;
}
displayName = (
<View style={style.botContainer}>
<Text style={style.displayName}>
{name}
</Text>
<Text style={style.bot}>
{BOT_NAME}
</Text>
</View>
);
messageStyle = style.message;
} else {
profilePicture = (
<TouchableOpacity onPress={this.viewUserProfile}>
<ProfilePicture
user={this.props.user}
size={PROFILE_PICTURE_SIZE}
/>
</TouchableOpacity>
);
if (this.props.displayName) {
displayName = (
<TouchableOpacity onPress={this.viewUserProfile}>
<Text style={style.displayName}>
{this.props.displayName}
</Text>
</TouchableOpacity>
);
} else {
displayName = (
<FormattedText
id='channel_loader.someone'
defaultMessage='Someone'
style={style.displayName}
/>
);
}
messageStyle = style.message;
}
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
const selected = this.state && this.state.selected ? style.selected : null;
let contents;
if (this.props.commentedOnPost) {
contents = (
<View style={[style.container, this.props.style, selected]}>
<View style={[style.profilePictureContainer, (isPostPendingOrFailed(post) && style.pendingPost)]}>
{profilePicture}
</View>
<View style={style.rightColumn}>
<View style={[style.postInfoContainer, (isPostPendingOrFailed(post) && style.pendingPost)]}>
{displayName}
<View style={style.timeContainer}>
<Text style={style.time}>
<FormattedTime value={this.props.post.create_at}/>
</Text>
</View>
</View>
<View>
{this.renderCommentedOnMessage(style)}
</View>
{this.renderMessage(style, messageStyle, blockStyles, textStyles, true)}
</View>
</View>
);
} else {
contents = (
<View style={[style.container, this.props.style, selected]}>
<View style={[style.profilePictureContainer, (isPostPendingOrFailed(post) && style.pendingPost)]}>
{profilePicture}
</View>
<View style={style.messageContainerWithReplyBar}>
{this.renderReplyBar(style)}
<View style={[style.rightColumn, (this.props.isLastReply && style.rightColumnPadding)]}>
<View style={[style.postInfoContainer, (isPostPendingOrFailed(post) && style.pendingPost)]}>
<View style={{flexDirection: 'row', flex: 1}}>
{displayName}
<View style={style.timeContainer}>
<Text style={style.time}>
<FormattedTime value={this.props.post.create_at}/>
</Text>
</View>
</View>
{(commentCount > 0 && renderReplies) &&
<TouchableOpacity
onPress={this.handlePress}
style={style.replyIconContainer}
>
<ReplyIcon
height={15}
width={15}
color={theme.linkColor}
/>
<Text style={style.replyText}>{commentCount}</Text>
</TouchableOpacity>
}
</View>
{this.renderMessage(style, messageStyle, blockStyles, textStyles)}
</View>
</View>
</View>
);
}
return contents;
</View>
);
}
}
@@ -675,11 +339,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
rightColumnPadding: {
paddingBottom: 3
},
postInfoContainer: {
alignItems: 'center',
flexDirection: 'row',
marginTop: 10
},
messageContainerWithReplyBar: {
flexDirection: 'row',
flex: 1
@@ -703,61 +362,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
replyBarLast: {
paddingBottom: 10
},
displayName: {
color: theme.centerChannelColor,
fontSize: 15,
fontWeight: '600',
marginRight: 5,
marginBottom: 3
},
botContainer: {
flexDirection: 'row'
},
bot: {
alignSelf: 'center',
backgroundColor: changeOpacity(theme.centerChannelColor, 0.15),
borderRadius: 2,
color: theme.centerChannelColor,
fontSize: 10,
fontWeight: '600',
marginRight: 5,
paddingVertical: 2,
paddingHorizontal: 4
},
time: {
color: theme.centerChannelColor,
fontSize: 13,
marginLeft: 5,
marginBottom: 1,
opacity: 0.5
},
timeContainer: {
justifyContent: 'center'
},
commentedOn: {
color: changeOpacity(theme.centerChannelColor, 0.65),
marginBottom: 3,
lineHeight: 21
},
message: {
color: theme.centerChannelColor,
fontSize: 15
},
systemMessage: {
opacity: 0.6
},
selected: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1)
},
replyIconContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
},
replyText: {
fontSize: 15,
marginLeft: 3,
color: theme.linkColor
highlight: {
backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.5)
}
});
});

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {flagPost, unflagPost} from 'mattermost-redux/actions/posts';
import {Posts} from 'mattermost-redux/constants';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences';
import {isPostFlagged, isSystemMessage} from 'mattermost-redux/utils/post_utils';
import {getTheme} from 'app/selectors/preferences';
import PostBody from './post_body';
function mapStateToProps(state, ownProps) {
const post = getPost(state, ownProps.postId);
const myPreferences = getMyPreferences(state);
return {
...ownProps,
attachments: post.props && post.props.attachments,
fileIds: post.file_ids,
hasBeenDeleted: post.state === Posts.POST_DELETED,
hasReactions: post.has_reactions,
isFailed: post.failed,
isFlagged: isPostFlagged(post.id, myPreferences),
isPending: post.id === post.pending_post_id,
isSystemMessage: isSystemMessage(post),
message: post.message,
theme: getTheme(state)
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
flagPost,
unflagPost
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(PostBody);

View File

@@ -0,0 +1,304 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Platform,
StyleSheet,
TouchableHighlight,
TouchableOpacity,
View
} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
import Icon from 'react-native-vector-icons/Ionicons';
import FileAttachmentList from 'app/components/file_attachment_list';
import FormattedText from 'app/components/formatted_text';
import Markdown from 'app/components/markdown';
import OptionsContext from 'app/components/options_context';
import SlackAttachments from 'app/components/slack_attachments';
import {emptyFunction} from 'app/utils/general';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from 'app/utils/markdown';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import Reactions from 'app/components/reactions';
class PostBody extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
flagPost: PropTypes.func.isRequired,
unflagPost: PropTypes.func.isRequired
}).isRequired,
attachments: PropTypes.array,
canDelete: PropTypes.bool,
canEdit: PropTypes.bool,
fileIds: PropTypes.array,
hasBeenDeleted: PropTypes.bool,
hasReactions: PropTypes.bool,
intl: intlShape.isRequired,
isFailed: PropTypes.bool,
isFlagged: PropTypes.bool,
isPending: PropTypes.bool,
isSearchResult: PropTypes.bool,
isSystemMessage: PropTypes.bool,
message: PropTypes.string,
navigator: PropTypes.object.isRequired,
onFailedPostPress: PropTypes.func,
onPostDelete: PropTypes.func,
onPostEdit: PropTypes.func,
onPress: PropTypes.func,
postId: PropTypes.string.isRequired,
renderReplyBar: PropTypes.func,
theme: PropTypes.object,
toggleSelected: PropTypes.func
};
static defaultProps = {
fileIds: [],
onFailedPostPress: emptyFunction,
onPostDelete: emptyFunction,
onPostEdit: emptyFunction,
onPress: emptyFunction,
renderReplyBar: emptyFunction,
toggleSelected: emptyFunction
};
handleHideUnderlay = () => {
this.props.toggleSelected(false);
};
handleShowUnderlay = () => {
this.props.toggleSelected(true);
};
hideOptionsContext = () => {
if (Platform.OS === 'ios' && this.refs.options) {
this.refs.options.hide();
}
};
flagPost = () => {
const {actions, postId} = this.props;
actions.flagPost(postId);
};
unflagPost = () => {
const {actions, postId} = this.props;
actions.unflagPost(postId);
};
showOptionsContext = () => {
if (this.refs.options) {
this.refs.options.show();
}
};
renderFileAttachments() {
const {
fileIds,
isFailed,
navigator,
onPress,
postId,
toggleSelected
} = this.props;
let attachments;
if (fileIds.length > 0) {
attachments = (
<FileAttachmentList
fileIds={fileIds}
hideOptionsContext={this.hideOptionsContext}
isFailed={isFailed}
onLongPress={this.showOptionsContext}
onPress={onPress}
postId={postId}
toggleSelected={toggleSelected}
navigator={navigator}
/>
);
}
return attachments;
}
renderSlackAttachments = (baseStyle, blockStyles, textStyles) => {
const {attachments, navigator, theme} = this.props;
if (attachments && attachments.length) {
return (
<SlackAttachments
attachments={attachments}
baseTextStyle={baseStyle}
blockStyles={blockStyles}
navigator={navigator}
textStyles={textStyles}
theme={theme}
/>
);
}
return null;
};
render() {
const {
canDelete,
canEdit,
hasBeenDeleted,
hasReactions,
isFailed,
isFlagged,
isPending,
isSearchResult,
isSystemMessage,
intl,
message,
navigator,
onFailedPostPress,
onPostDelete,
onPostEdit,
onPress,
postId,
renderReplyBar,
theme,
toggleSelected
} = this.props;
const {formatMessage} = intl;
const actions = [];
const style = getStyleSheet(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
const messageStyle = isSystemMessage ? [style.message, style.systemMessage] : style.message;
const isPendingOrFailedPost = isPending || isFailed;
// we should check for the user roles and permissions
if (!isPendingOrFailedPost && !isSearchResult) {
if (isFlagged) {
actions.push({
text: formatMessage({id: 'post_info.mobile.unflag', defaultMessage: 'Unflag'}),
onPress: this.unflagPost
});
} else {
actions.push({
text: formatMessage({id: 'post_info.mobile.flag', defaultMessage: 'Flag'}),
onPress: this.flagPost
});
}
if (canEdit) {
actions.push({text: formatMessage({id: 'post_info.edit', defaultMessage: 'Edit'}), onPress: onPostEdit});
}
if (canDelete && !hasBeenDeleted) {
actions.push({text: formatMessage({id: 'post_info.del', defaultMessage: 'Delete'}), onPress: onPostDelete});
}
}
let messageComponent;
if (hasBeenDeleted) {
messageComponent = (
<FormattedText
style={messageStyle}
id='post_body.deleted'
defaultMessage='(message deleted)'
/>
);
} else if (message.length) {
messageComponent = (
<View style={{flexDirection: 'row'}}>
<View style={[{flex: 1}, (isPendingOrFailedPost && style.pendingPost)]}>
<Markdown
baseTextStyle={messageStyle}
textStyles={textStyles}
blockStyles={blockStyles}
isSearchResult={isSearchResult}
value={message}
onLongPress={this.showOptionsContext}
onPostPress={onPress}
navigator={navigator}
/>
</View>
</View>
);
}
let body;
if (isSearchResult) {
body = (
<TouchableHighlight
onHideUnderlay={this.handleHideUnderlay}
onLongPress={this.show}
onPress={onPress}
onShowUnderlay={this.handleShowUnderlay}
underlayColor='transparent'
>
<View>
{messageComponent}
{this.renderSlackAttachments(messageStyle, blockStyles, textStyles)}
{this.renderFileAttachments()}
</View>
</TouchableHighlight>
);
} else {
body = (
<OptionsContext
actions={actions}
ref='options'
onPress={onPress}
toggleSelected={toggleSelected}
cancelText={formatMessage({id: 'channel_modal.cancel', defaultMessage: 'Cancel'})}
>
{messageComponent}
{this.renderSlackAttachments(messageStyle, blockStyles, textStyles)}
{this.renderFileAttachments()}
{hasReactions && <Reactions postId={postId}/>}
</OptionsContext>
);
}
return (
<View style={style.messageContainerWithReplyBar}>
{renderReplyBar()}
<View style={{flex: 1, flexDirection: 'row'}}>
<View style={{flex: 1}}>
{body}
</View>
{isFailed &&
<TouchableOpacity
onPress={onFailedPostPress}
style={{justifyContent: 'center', marginLeft: 12}}
>
<Icon
name='ios-information-circle-outline'
size={26}
color={theme.errorTextColor}
/>
</TouchableOpacity>
}
</View>
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
message: {
color: theme.centerChannelColor,
fontSize: 15
},
messageContainerWithReplyBar: {
flexDirection: 'row',
flex: 1
},
pendingPost: {
opacity: 0.5
},
systemMessage: {
opacity: 0.6
}
});
});
export default injectIntl(PostBody);

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getPost, makeGetCommentCountForPost} from 'mattermost-redux/selectors/entities/posts';
import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {isPostPendingOrFailed, isSystemMessage} from 'mattermost-redux/utils/post_utils';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
import {getTheme} from 'app/selectors/preferences';
import PostHeader from './post_header';
function makeMapStateToProps() {
const getCommentCountForPost = makeGetCommentCountForPost();
return function mapStateToProps(state, ownProps) {
const {config} = state.entities.general;
const post = getPost(state, ownProps.postId);
const commentedOnUser = getUser(state, ownProps.commentedOnUserId);
const user = getUser(state, post.user_id);
const teammateNameDisplay = getTeammateNameDisplaySetting(state);
return {
...ownProps,
commentedOnDisplayName: displayUsername(commentedOnUser, teammateNameDisplay),
commentCount: getCommentCountForPost(state, {post}),
createAt: post.create_at,
displayName: displayUsername(user, teammateNameDisplay),
enablePostUsernameOverride: config.EnablePostUsernameOverride === 'true',
fromWebHook: post.props && post.props.from_webhook === 'true',
isPendingOrFailedPost: isPostPendingOrFailed(post),
isSystemMessage: isSystemMessage(post),
overrideUsername: post.props && post.props.override_username,
theme: getTheme(state)
};
};
}
export default connect(makeMapStateToProps)(PostHeader);

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import FormattedTime from 'app/components/formatted_time';
import ReplyIcon from 'app/components/reply_icon';
import {emptyFunction} from 'app/utils/general';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const BOT_NAME = 'BOT';
export default class PostHeader extends PureComponent {
static propTypes = {
commentCount: PropTypes.number,
commentedOnDisplayName: PropTypes.string,
createAt: PropTypes.number.isRequired,
displayName: PropTypes.string.isRequired,
enablePostUsernameOverride: PropTypes.bool,
fromWebHook: PropTypes.bool,
isPendingOrFailedPost: PropTypes.bool,
isSearchResult: PropTypes.bool,
shouldRenderReplyButton: PropTypes.bool,
isSystemMessage: PropTypes.bool,
onPress: PropTypes.func,
onViewUserProfile: PropTypes.func,
overrideUsername: PropTypes.string,
renderReplies: PropTypes.bool,
theme: PropTypes.object.isRequired
};
static defaultProps = {
commentCount: 0,
onPress: emptyFunction,
onViewUserProfile: emptyFunction
};
getDisplayName = (style) => {
const {
enablePostUsernameOverride,
fromWebHook,
isSystemMessage,
onViewUserProfile,
overrideUsername
} = this.props;
if (isSystemMessage) {
return (
<FormattedText
id='post_info.system'
defaultMessage='System'
style={style.displayName}
/>
);
} else if (fromWebHook) {
let name = this.props.displayName;
if (overrideUsername && enablePostUsernameOverride) {
name = overrideUsername;
}
return (
<View style={style.botContainer}>
<Text style={style.displayName}>
{name}
</Text>
<Text style={style.bot}>
{BOT_NAME}
</Text>
</View>
);
} else if (this.props.displayName) {
return (
<TouchableOpacity onPress={onViewUserProfile}>
<Text style={style.displayName}>
{this.props.displayName}
</Text>
</TouchableOpacity>
);
}
return (
<FormattedText
id='channel_loader.someone'
defaultMessage='Someone'
style={style.displayName}
/>
);
};
renderCommentedOnMessage = (style) => {
if (!this.props.renderReplies || !this.props.commentedOnDisplayName) {
return null;
}
const displayName = this.props.commentedOnDisplayName;
let name;
if (displayName) {
name = displayName;
} else {
name = (
<FormattedText
id='channel_loader.someone'
defaultMessage='Someone'
/>
);
}
let apostrophe;
if (displayName && displayName.slice(-1) === 's') {
apostrophe = '\'';
} else {
apostrophe = '\'s';
}
return (
<FormattedText
id='post_body.commentedOn'
defaultMessage='Commented on {name}{apostrophe} message: '
values={{
name,
apostrophe
}}
style={style.commentedOn}
/>
);
};
render() {
const {
commentedOnDisplayName,
commentCount,
createAt,
isPendingOrFailedPost,
isSearchResult,
onPress,
renderReplies,
shouldRenderReplyButton,
theme
} = this.props;
const style = getStyleSheet(theme);
const showReply = shouldRenderReplyButton || (!commentedOnDisplayName && commentCount > 0 && renderReplies);
return (
<View>
<View style={[style.postInfoContainer, (isPendingOrFailedPost && style.pendingPost)]}>
<View style={{flexDirection: 'row', flex: 1}}>
{this.getDisplayName(style)}
<View style={style.timeContainer}>
<Text style={style.time}>
<FormattedTime value={createAt}/>
</Text>
</View>
</View>
{showReply &&
<TouchableOpacity
onPress={onPress}
style={style.replyIconContainer}
>
<ReplyIcon
height={15}
width={15}
color={theme.linkColor}
/>
{!isSearchResult &&
<Text style={style.replyText}>{commentCount}</Text>
}
</TouchableOpacity>
}
</View>
{commentedOnDisplayName !== '' &&
<View>
{this.renderCommentedOnMessage(style)}
</View>
}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
commentedOn: {
color: changeOpacity(theme.centerChannelColor, 0.65),
marginBottom: 3,
lineHeight: 21
},
postInfoContainer: {
alignItems: 'center',
flexDirection: 'row',
marginTop: 10
},
pendingPost: {
opacity: 0.5
},
timeContainer: {
justifyContent: 'center'
},
time: {
color: theme.centerChannelColor,
fontSize: 13,
marginLeft: 5,
marginBottom: 1,
opacity: 0.5
},
replyIconContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
},
replyText: {
fontSize: 15,
marginLeft: 3,
color: theme.linkColor
},
botContainer: {
flexDirection: 'row'
},
bot: {
alignSelf: 'center',
backgroundColor: changeOpacity(theme.centerChannelColor, 0.15),
borderRadius: 2,
color: theme.centerChannelColor,
fontSize: 10,
fontWeight: '600',
marginRight: 5,
paddingVertical: 2,
paddingHorizontal: 4
},
displayName: {
color: theme.centerChannelColor,
fontSize: 15,
fontWeight: '600',
marginRight: 5,
marginBottom: 3
}
});
});

View File

@@ -4,18 +4,14 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {refreshChannel} from 'app/actions/views/channel';
import {refreshChannelWithRetry} from 'app/actions/views/channel';
import {getTheme} from 'app/selectors/preferences';
import PostList from './post_list';
function mapStateToProps(state, ownProps) {
const {loading, refreshing} = state.views.channel;
return {
...ownProps,
channelIsLoading: loading,
refreshing,
theme: getTheme(state)
};
}
@@ -23,7 +19,7 @@ function mapStateToProps(state, ownProps) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
refreshChannel
refreshChannelWithRetry
}, dispatch)
};
}

View File

@@ -22,13 +22,14 @@ const LOAD_MORE_POSTS = 'load-more-posts';
export default class PostList extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
refreshChannel: PropTypes.func.isRequired
refreshChannelWithRetry: PropTypes.func.isRequired
}).isRequired,
channel: PropTypes.object,
channelIsLoading: PropTypes.bool.isRequired,
currentUserId: PropTypes.string,
indicateNewMessages: PropTypes.bool,
isLoadingMore: PropTypes.bool,
isSearchResult: PropTypes.bool,
lastViewedAt: PropTypes.number,
loadMore: PropTypes.func,
navigator: PropTypes.object,
@@ -37,6 +38,7 @@ export default class PostList extends PureComponent {
refreshing: PropTypes.bool,
renderReplies: PropTypes.bool,
showLoadMore: PropTypes.bool,
shouldRenderReplyButton: PropTypes.bool,
theme: PropTypes.object.isRequired
};
@@ -77,7 +79,7 @@ export default class PostList extends PureComponent {
const {actions, channel} = this.props;
if (Object.keys(channel).length) {
actions.refreshChannel(channel.id);
actions.refreshChannelWithRetry(channel.id);
}
};
@@ -128,15 +130,25 @@ export default class PostList extends PureComponent {
};
renderPost = (post) => {
const {
isSearchResult,
navigator,
onPostPress,
renderReplies,
shouldRenderReplyButton
} = this.props;
return (
<Post
post={post}
renderReplies={this.props.renderReplies}
renderReplies={renderReplies}
isFirstReply={post.isFirstReply}
isLastReply={post.isLastReply}
isSearchResult={isSearchResult}
shouldRenderReplyButton={shouldRenderReplyButton}
commentedOnPost={post.commentedOnPost}
onPress={this.props.onPostPress}
navigator={this.props.navigator}
onPress={onPostPress}
navigator={navigator}
/>
);
};
@@ -164,6 +176,7 @@ export default class PostList extends PureComponent {
{...refreshControl}
renderItem={this.renderItem}
theme={theme}
keyboardShouldPersistTaps='handled'
/>
);
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {isSystemMessage} from 'mattermost-redux/utils/post_utils';
import {getTheme} from 'app/selectors/preferences';
import PostProfilePicture from './post_profile_picture';
function mapStateToProps(state, ownProps) {
const {config} = state.entities.general;
const post = getPost(state, ownProps.postId);
const user = getUser(state, post.user_id);
return {
...ownProps,
enablePostIconOverride: config.EnablePostIconOverride === 'true',
fromWebHook: post.props && post.props.from_webhook === 'true',
isSystemMessage: isSystemMessage(post),
overrideIconUrl: post.props && post.props.override_icon_url,
user,
theme: getTheme(state)
};
}
export default connect(mapStateToProps)(PostProfilePicture);

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {Image, TouchableOpacity, View} from 'react-native';
import MattermostIcon from 'app/components/mattermost_icon';
import ProfilePicture from 'app/components/profile_picture';
import {emptyFunction} from 'app/utils/general';
import webhookIcon from 'assets/images/icons/webhook.jpg';
const PROFILE_PICTURE_SIZE = 32;
function PostProfilePicture(props) {
const {
enablePostIconOverride,
fromWebHook,
isSystemMessage,
onViewUserProfile,
overrideIconUrl,
theme,
user
} = props;
if (isSystemMessage) {
return (
<View>
<MattermostIcon
color={theme.centerChannelColor}
height={PROFILE_PICTURE_SIZE}
width={PROFILE_PICTURE_SIZE}
/>
</View>
);
} else if (fromWebHook) {
if (enablePostIconOverride) {
const icon = overrideIconUrl ? {uri: overrideIconUrl} : webhookIcon;
return (
<View>
<Image
source={icon}
style={{
height: PROFILE_PICTURE_SIZE,
width: PROFILE_PICTURE_SIZE,
borderRadius: PROFILE_PICTURE_SIZE / 2
}}
/>
</View>
);
}
return (
<ProfilePicture
user={user}
size={PROFILE_PICTURE_SIZE}
/>
);
}
return (
<TouchableOpacity onPress={onViewUserProfile}>
<ProfilePicture
user={user}
size={PROFILE_PICTURE_SIZE}
/>
</TouchableOpacity>
);
}
PostProfilePicture.propTypes = {
enablePostIconOverride: PropTypes.bool,
fromWebHook: PropTypes.bool,
isSystemMessage: PropTypes.bool,
overrideIconUrl: PropTypes.string,
onViewUserProfile: PropTypes.func,
theme: PropTypes.object,
user: PropTypes.object
};
PostProfilePicture.defaultProps = {
onViewUserProfile: emptyFunction
};
export default PostProfilePicture;

View File

@@ -8,7 +8,7 @@ import {userTyping} from 'mattermost-redux/actions/websocket';
import {handleClearFiles, handleRemoveLastFile, handleUploadFiles} from 'app/actions/views/file_upload';
import {getTheme} from 'app/selectors/preferences';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {canUploadFilesOnMobile} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getUsersTyping} from 'mattermost-redux/selectors/entities/typing';
@@ -17,8 +17,8 @@ import PostTextbox from './post_textbox';
function mapStateToProps(state, ownProps) {
return {
...ownProps,
canUploadFiles: canUploadFilesOnMobile(state),
channelIsLoading: state.views.channel.loading,
config: getConfig(state),
currentUserId: getCurrentUserId(state),
typing: getUsersTyping(state),
theme: getTheme(state),

View File

@@ -38,9 +38,9 @@ class PostTextbox extends PureComponent {
handleUploadFiles: PropTypes.func.isRequired,
userTyping: PropTypes.func.isRequired
}).isRequired,
canUploadFiles: PropTypes.bool.isRequired,
channelId: PropTypes.string.isRequired,
channelIsLoading: PropTypes.bool.isRequired,
config: PropTypes.object.isRequired,
currentUserId: PropTypes.string.isRequired,
files: PropTypes.array,
intl: intlShape.isRequired,
@@ -113,8 +113,11 @@ class PostTextbox extends PureComponent {
const {files, uploadFileRequestStatus, value} = this.props;
const valueLength = value.trim().length;
return (valueLength > 0 && valueLength <= MAX_MESSAGE_LENGTH) ||
(files.filter((f) => !f.failed).length > 0 && uploadFileRequestStatus !== RequestStatus.STARTED);
if (files.length) {
return valueLength <= MAX_MESSAGE_LENGTH && uploadFileRequestStatus !== RequestStatus.STARTED && files.filter((f) => !f.failed).length > 0;
}
return valueLength > 0 && valueLength <= MAX_MESSAGE_LENGTH;
};
handleAndroidKeyboard = () => {
@@ -389,8 +392,53 @@ class PostTextbox extends PureComponent {
}
};
renderDisabledSendButton = () => {
const {theme} = this.props;
const style = getStyleSheet(theme);
return (
<View style={[style.sendButton, style.disableButton]}>
<PaperPlane
height={13}
width={15}
color={theme.buttonColor}
/>
</View>
);
}
renderSendButton = () => {
const {theme, uploadFileRequestStatus} = this.props;
const style = getStyleSheet(theme);
if (uploadFileRequestStatus === RequestStatus.STARTED) {
return this.renderDisabledSendButton();
} else if (this.canSend()) {
return (
<TouchableOpacity
onPress={this.handleSendMessage}
style={style.sendButton}
>
<PaperPlane
height={13}
width={15}
color={theme.buttonColor}
/>
</TouchableOpacity>
);
}
return null;
}
render() {
const {channelIsLoading, config, intl, theme, value} = this.props;
const {
canUploadFiles,
channelIsLoading,
intl,
theme,
value
} = this.props;
const style = getStyleSheet(theme);
const textInputHeight = Math.min(this.state.contentHeight, MAX_CONTENT_HEIGHT);
@@ -406,7 +454,7 @@ class PostTextbox extends PureComponent {
let fileUpload = null;
const inputContainerStyle = [style.inputContainer];
if (!config.EnableFileAttachments || config.EnableFileAttachments === 'true') {
if (canUploadFiles) {
fileUpload = (
<TouchableOpacity
onPress={this.showFileAttachmentOptions}
@@ -470,18 +518,7 @@ class PostTextbox extends PureComponent {
onSubmitEditing={this.handleSubmit}
onLayout={this.handleInputSizeChange}
/>
{this.canSend() &&
<TouchableOpacity
onPress={this.handleSendMessage}
style={style.sendButton}
>
<PaperPlane
height={13}
width={15}
color={theme.buttonColor}
/>
</TouchableOpacity>
}
{this.renderSendButton()}
</View>
</View>
</View>
@@ -500,6 +537,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
alignItems: 'center',
justifyContent: 'center'
},
disableButton: {
backgroundColor: changeOpacity(theme.buttonBg, 0.3)
},
input: {
color: '#000',
flex: 1,

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {addReaction, getReactionsForPost, removeReaction} from 'mattermost-redux/actions/posts';
import {makeGetReactionsForPost} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getTheme} from 'app/selectors/preferences';
import Reactions from './reactions';
function makeMapStateToProps() {
const getReactionsForPostSelector = makeGetReactionsForPost();
return function mapStateToProps(state, ownProps) {
const currentUserId = getCurrentUserId(state);
const reactionsForPost = getReactionsForPostSelector(state, ownProps.postId);
const highlightedReactions = [];
const reactionsByName = reactionsForPost.reduce((reactions, reaction) => {
if (reactions.has(reaction.emoji_name)) {
reactions.get(reaction.emoji_name).push(reaction);
} else {
reactions.set(reaction.emoji_name, [reaction]);
}
if (reaction.user_id === currentUserId) {
highlightedReactions.push(reaction.emoji_name);
}
return reactions;
}, new Map());
return {
highlightedReactions,
reactions: reactionsByName,
theme: getTheme(state)
};
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
addReaction,
getReactionsForPost,
removeReaction
}, dispatch)
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(Reactions);

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
Text,
TouchableOpacity
} from 'react-native';
import Emoji from 'app/components/emoji';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
export default class Reaction extends PureComponent {
static propTypes = {
count: PropTypes.number.isRequired,
emojiName: PropTypes.string.isRequired,
highlight: PropTypes.bool.isRequired,
onPress: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired
}
handlePress = () => {
const {emojiName, highlight, onPress} = this.props;
onPress(emojiName, highlight);
}
render() {
const {count, emojiName, highlight, theme} = this.props;
const styles = getStyleSheet(theme);
return (
<TouchableOpacity
onPress={this.handlePress}
style={[styles.reaction, (highlight && styles.highlight)]}
>
<Emoji
emojiName={emojiName}
size={15}
padding={5}
/>
<Text style={styles.count}>{count}</Text>
</TouchableOpacity>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
count: {
color: theme.linkColor,
marginLeft: 6
},
highlight: {
backgroundColor: changeOpacity(theme.linkColor, 0.1)
},
reaction: {
alignItems: 'center',
borderRadius: 2,
borderColor: changeOpacity(theme.linkColor, 0.4),
borderWidth: 1,
flexDirection: 'row',
marginRight: 6,
marginVertical: 5,
paddingVertical: 2,
paddingHorizontal: 6
}
});
});

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
View
} from 'react-native';
import Reaction from './reaction';
export default class Reactions extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
addReaction: PropTypes.func.isRequired,
getReactionsForPost: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired
}).isRequired,
highlightedReactions: PropTypes.array.isRequired,
postId: PropTypes.string.isRequired,
reactions: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired
}
componentDidMount() {
const {actions, postId} = this.props;
actions.getReactionsForPost(postId);
}
handleReactionPress = (emoji, remove) => {
const {actions, postId} = this.props;
if (remove) {
actions.removeReaction(postId, emoji);
} else {
actions.addReaction(postId, emoji);
}
}
renderReactions = () => {
const {highlightedReactions, reactions, theme} = this.props;
return Array.from(reactions.keys()).map((r) => {
return (
<Reaction
key={r}
count={reactions.get(r).length}
emojiName={r}
highlight={highlightedReactions.includes(r)}
onPress={this.handleReactionPress}
theme={theme}
/>
);
});
}
render() {
return (
<View style={style.reactions}>
{this.renderReactions()}
</View>
);
}
}
const style = StyleSheet.create({
reactions: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'flex-start'
}
});

View File

@@ -23,7 +23,7 @@ function mapStateToProps(state, ownProps) {
}
Client.setLocale(locale);
Client4.setLocale(locale);
Client4.setAcceptLanguage(locale);
return {
...ownProps,

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {SectionList} from 'react-native';
import MetroListView from 'react-native/Libraries/Lists/MetroListView';
import VirtualizedSectionList from './virtualized_section_list';
export default class ScrollableSectionList extends SectionList {
getWrapperRef = () => {
return this._wrapperListRef; //eslint-disable-line no-underscore-dangle
};
render() {
const List = this.props.legacyImplementation ?
MetroListView :
VirtualizedSectionList;
return (
<List
{...this.props}
ref={this._captureRef} //eslint-disable-line no-underscore-dangle
/>
);
}
_wrapperListRef: MetroListView | VirtualizedSectionList<any>; //eslint-disable-line no-underscore-dangle
_captureRef = (ref) => {
this._wrapperListRef = ref; //eslint-disable-line no-underscore-dangle
};
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {VirtualizedList} from 'react-native';
import VirtualizedSectionList from 'react-native/Libraries/Lists/VirtualizedSectionList';
export default class VirtualizedScrollableSectionList extends VirtualizedSectionList {
getListRef() {
return this._listRef; //eslint-disable-line no-underscore-dangle
}
render() {
return (
<VirtualizedList
{...this.state.childProps}
ref={this._captureRef} //eslint-disable-line no-underscore-dangle
/>
);
}
_listRef: VirtualizedList;
_captureRef = (ref) => {
this._listRef = ref; //eslint-disable-line no-underscore-dangle
};
}

View File

@@ -27,6 +27,7 @@ export default class SearchBarAndroid extends PureComponent {
onSearchButtonPress: PropTypes.func,
onSelectionChange: PropTypes.func,
backgroundColor: PropTypes.string,
selectionColor: PropTypes.string,
placeholderTextColor: PropTypes.string,
titleCancelColor: PropTypes.string,
tintColorSearch: PropTypes.string,
@@ -132,6 +133,7 @@ export default class SearchBarAndroid extends PureComponent {
keyboardType,
placeholder,
placeholderTextColor,
selectionColor,
returnKeyType,
titleCancelColor,
tintColorDelete,
@@ -204,6 +206,7 @@ export default class SearchBarAndroid extends PureComponent {
onSelectionChange={this.onSelectionChange}
placeholder={placeholder}
placeholderTextColor={placeholderTextColor}
selectionColor={selectionColor}
underlineColorAndroid='transparent'
style={[
styles.searchBarInput,

View File

@@ -36,6 +36,7 @@ export default class Search extends Component {
titleCancelColor: PropTypes.string,
tintColorSearch: PropTypes.string,
tintColorDelete: PropTypes.string,
selectionColor: PropTypes.string,
inputStyle: PropTypes.oneOfType([
PropTypes.number,
PropTypes.object,
@@ -103,7 +104,6 @@ export default class Search extends Component {
super(props);
this.state = {
keyword: props.value || '',
expanded: false
};
const {width} = Dimensions.get('window');
@@ -126,6 +126,8 @@ export default class Search extends Component {
if (this.props.value !== nextProps.value) {
if (nextProps.value) {
this.iconDeleteAnimated = new Animated.Value(1);
} else {
this.iconDeleteAnimated = new Animated.Value(0);
}
}
}
@@ -201,7 +203,6 @@ export default class Search extends Component {
duration: 200
}
).start();
this.setState({keyword: ''});
if (this.props.onDelete) {
this.props.onDelete();
@@ -209,7 +210,7 @@ export default class Search extends Component {
};
onCancel = async () => {
this.setState({keyword: '', expanded: false});
this.setState({expanded: false});
await this.collapseAnimation(true);
if (this.props.onCancel) {
@@ -367,6 +368,7 @@ export default class Search extends Component {
onChangeText={this.onChangeText}
placeholder={this.placeholder}
placeholderTextColor={this.props.placeholderTextColor}
selectionColor={this.props.selectionColor}
onSubmitEditing={this.onSearch}
onSelectionChange={this.onSelectionChange}
autoCorrect={false}
@@ -415,20 +417,20 @@ export default class Search extends Component {
>
{this.props.iconDelete}
</Animated.View> :
<AnimatedIcon
name='ios-close-circle'
size={iconSize}
style={[
styles.iconDelete,
styles.iconDeleteDefault,
this.props.tintColorDelete && {color: this.props.tintColorDelete},
this.props.positionRightDelete && {right: this.props.positionRightDelete},
{
opacity: this.iconDeleteAnimated,
top: middleHeight - (iconSize / 2)
}
]}
/>
<View style={[styles.iconDelete, this.props.inputHeight && {height: this.props.inputHeight, width: iconSize + 5}]}>
<AnimatedIcon
name='ios-close-circle'
size={iconSize}
style={[
styles.iconDeleteDefault,
this.props.tintColorDelete && {color: this.props.tintColorDelete},
this.props.positionRightDelete && {right: this.props.positionRightDelete},
{
opacity: this.iconDeleteAnimated
}
]}
/>
</View>
)}
</TouchableWithoutFeedback>
<TouchableWithoutFeedback onPress={this.onCancel}>
@@ -462,10 +464,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
paddingBottom: 5,
paddingLeft: 5,
paddingRight: 5,
paddingTop: 4
padding: 5
},
input: {
height: containerHeight - 10,
@@ -485,6 +484,8 @@ const styles = StyleSheet.create({
color: 'grey'
},
iconDelete: {
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
right: 70
},

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getPost, makeGetPostsAroundPost} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import SearchPreview from './search_preview';
function makeMapStateToProps() {
const getPostsAroundPost = makeGetPostsAroundPost();
return function mapStateToProps(state, ownProps) {
const post = getPost(state, ownProps.focusedPostId);
const postsAroundPost = getPostsAroundPost(state, post.id, post.channel_id);
const focusedPostIndex = postsAroundPost ? postsAroundPost.findIndex((p) => p.id === ownProps.focusedPostId) : -1;
let posts = [];
if (focusedPostIndex !== -1) {
const desiredPostIndexBefore = focusedPostIndex - 5;
const minPostIndex = desiredPostIndexBefore < 0 ? 0 : desiredPostIndexBefore;
posts = [...postsAroundPost].splice(minPostIndex, 10);
}
return {
...ownProps,
channelId: post.channel_id,
currentUserId: getCurrentUserId(state),
posts
};
};
}
export default connect(makeMapStateToProps, null, null, {withRef: true})(SearchPreview);

View File

@@ -0,0 +1,234 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Dimensions,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import * as Animatable from 'react-native-animatable';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import FormattedText from 'app/components/formatted_text';
import Loading from 'app/components/loading';
import PostList from 'app/components/post_list';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
Animatable.initializeRegistryWithDefinitions({
growOut: {
from: {
opacity: 1,
scale: 1
},
0.5: {
opacity: 1,
scale: 3
},
to: {
opacity: 0,
scale: 5
}
}
});
export default class SearchPreview extends PureComponent {
static propTypes = {
channelId: PropTypes.string,
channelName: PropTypes.string,
currentUserId: PropTypes.string.isRequired,
navigator: PropTypes.object,
onClose: PropTypes.func,
onPress: PropTypes.func,
posts: PropTypes.array,
theme: PropTypes.object.isRequired
};
static defaultProps = {
posts: []
};
state = {
showPosts: false
};
handleClose = () => {
this.refs.view.zoomOut().then(() => {
if (this.props.onClose) {
this.props.onClose();
}
});
return true;
};
handlePress = () => {
const {channelId, onPress} = this.props;
this.refs.view.growOut().then(() => {
if (onPress) {
onPress(channelId);
}
});
};
showPostList = () => {
if (!this.state.showPosts && this.props.posts.length) {
this.setState({showPosts: true});
}
};
render() {
const {channelName, currentUserId, posts, theme} = this.props;
const {height, width} = Dimensions.get('window');
const style = getStyleSheet(theme);
let postList;
if (this.state.showPosts) {
postList = (
<PostList
indicateNewMessages={false}
isSearchResult={true}
shouldRenderReplyButton={false}
renderReplies={false}
posts={posts}
currentUserId={currentUserId}
lastViewedAt={0}
navigator={navigator}
/>
);
} else {
postList = <Loading/>;
}
return (
<View
style={[style.container, {width, height}]}
>
<Animatable.View
ref='view'
animation='zoomIn'
duration={500}
delay={0}
style={style.wrapper}
onAnimationEnd={this.showPostList}
>
<View
style={style.header}
>
<TouchableOpacity
style={style.close}
onPress={this.handleClose}
>
<MaterialIcon
name='close'
size={20}
color={theme.centerChannelColor}
/>
</TouchableOpacity>
<View style={style.titleContainer}>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.title}
>
{channelName}
</Text>
</View>
</View>
<View style={style.postList}>
{postList}
</View>
<TouchableOpacity
style={style.footer}
onPress={this.handlePress}
>
<FormattedText
id='mobile.search.jump'
defautMessage='JUMP'
style={style.jump}
/>
</TouchableOpacity>
</Animatable.View>
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
position: 'absolute',
backgroundColor: changeOpacity('#000', 0.3),
top: 0,
left: 0,
zIndex: 10
},
wrapper: {
flex: 1,
marginHorizontal: 10,
opacity: 0,
...Platform.select({
android: {
marginTop: 10,
marginBottom: 35
},
ios: {
marginTop: 20,
marginBottom: 10
}
})
},
header: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2),
borderBottomWidth: 1,
borderTopLeftRadius: 6,
borderTopRightRadius: 6,
flexDirection: 'row',
height: 44,
paddingRight: 16,
width: '100%'
},
close: {
justifyContent: 'center',
height: 44,
width: 40,
paddingLeft: 7
},
titleContainer: {
alignItems: 'center',
flex: 1,
paddingRight: 40
},
title: {
color: theme.centerChannelColor,
fontSize: 17,
fontWeight: '600'
},
postList: {
backgroundColor: theme.centerChannelBg,
flex: 1
},
footer: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.buttonBg,
borderBottomLeftRadius: 6,
borderBottomRightRadius: 6,
flexDirection: 'row',
height: 44,
paddingRight: 16,
width: '100%'
},
jump: {
color: theme.buttonColor,
fontSize: 16,
fontWeight: '600',
textAlignVertical: 'center'
}
});
});

View File

@@ -15,11 +15,12 @@ export default class SlackAttachments extends PureComponent {
baseTextStyle: CustomPropTypes.Style,
blockStyles: PropTypes.object,
textStyles: PropTypes.object,
navigator: PropTypes.object.isRequired,
theme: PropTypes.object
};
render() {
const {attachments, baseTextStyle, blockStyles, textStyles, theme} = this.props;
const {attachments, baseTextStyle, blockStyles, navigator, textStyles, theme} = this.props;
const content = [];
attachments.forEach((attachment, i) => {
@@ -30,6 +31,7 @@ export default class SlackAttachments extends PureComponent {
blockStyles={blockStyles}
key={'att_' + i}
textStyles={textStyles}
navigator={navigator}
theme={theme}
/>
);

View File

@@ -21,6 +21,7 @@ export default class SlackAttachment extends PureComponent {
attachment: PropTypes.object.isRequired,
baseTextStyle: CustomPropTypes.Style,
blockStyles: PropTypes.object,
navigator: PropTypes.object.isRequired,
textStyles: PropTypes.object,
theme: PropTypes.object
};
@@ -61,6 +62,7 @@ export default class SlackAttachment extends PureComponent {
attachment,
baseTextStyle,
blockStyles,
navigator,
textStyles
} = this.props;
const fields = attachment.fields;
@@ -115,6 +117,7 @@ export default class SlackAttachment extends PureComponent {
textStyles={textStyles}
blockStyles={blockStyles}
value={(field.value || '')}
navigator={navigator}
/>
</View>
</View>
@@ -167,6 +170,7 @@ export default class SlackAttachment extends PureComponent {
baseTextStyle,
blockStyles,
textStyles,
navigator,
theme
} = this.props;
@@ -181,6 +185,7 @@ export default class SlackAttachment extends PureComponent {
textStyles={textStyles}
blockStyles={blockStyles}
value={attachment.pretext}
navigator={navigator}
/>
</View>
);
@@ -288,6 +293,7 @@ export default class SlackAttachment extends PureComponent {
textStyles={textStyles}
blockStyles={blockStyles}
value={this.state.text}
navigator={navigator}
/>
{moreLess}
</View>

View File

@@ -14,6 +14,7 @@ const ViewTypes = keyMirror({
POST_DRAFT_CHANGED: null,
COMMENT_DRAFT_CHANGED: null,
SEARCH_DRAFT_CHANGED: null,
NOTIFICATION_CHANGED: null,
NOTIFICATION_IN_APP: null,
@@ -35,7 +36,6 @@ const ViewTypes = keyMirror({
ADD_FILE_TO_FETCH_CACHE: null,
SET_CHANNEL_LOADER: null,
SET_CHANNEL_REFRESHING: null,
SET_CHANNEL_DISPLAY_NAME: null,
POST_TOOLTIP_VISIBLE: null,

View File

@@ -251,7 +251,6 @@ const state = {
channel: {
drafts: {},
loading: false,
refreshing: false,
tooltipVisible: false
},
connection: true,

View File

@@ -18,16 +18,16 @@ import {setJSExceptionHandler} from 'react-native-exception-handler';
import StatusBarSizeIOS from 'react-native-status-bar-size';
import semver from 'semver';
import {General} from 'mattermost-redux/constants';
import {setAppState, setDeviceToken, setServerVersion} from 'mattermost-redux/actions/general';
import {markChannelAsRead} from 'mattermost-redux/actions/channels';
import {logError} from 'mattermost-redux/actions/errors';
import {logout} from 'mattermost-redux/actions/users';
import {close as closeWebSocket} from 'mattermost-redux/actions/websocket';
import {Client4} from 'mattermost-redux/client';
import {General} from 'mattermost-redux/constants';
import {Client, Client4} from 'mattermost-redux/client';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {goToNotification, loadConfigAndLicense, queueNotification, setStatusBarHeight} from 'app/actions/views/root';
import {goToNotification, loadConfigAndLicense, queueNotification, setStatusBarHeight, purgeOfflineStore} from 'app/actions/views/root';
import {setChannelDisplayName} from 'app/actions/views/channel';
import {NavigationTypes, ViewTypes} from 'app/constants';
import {getTranslations} from 'app/i18n';
@@ -44,6 +44,12 @@ registerScreens(store, Provider);
export default class Mattermost {
constructor() {
if (Platform.OS === 'android') {
// This is to remove the warnings for the scaleY property used in android.
// The property is necessary because some Android devices won't render the posts
// properly if we use transform: {scaleY: -1} in the stylesheet.
console.ignoredYellowBox = ['`scaleY`']; //eslint-disable-line
}
this.isConfigured = false;
setJSExceptionHandler(this.errorHandler, false);
Orientation.lockToPortrait();
@@ -80,7 +86,7 @@ export default class Mattermost {
text: intl.formatMessage({id: 'mobile.error_handler.button', defaultMessage: 'Relaunch'}),
onPress: () => {
// purge the store
store.dispatch({type: General.OFFLINE_STORE_PURGE});
store.dispatch(purgeOfflineStore());
}
}],
{cancelable: false}
@@ -131,6 +137,8 @@ export default class Mattermost {
handleReset = () => {
const {dispatch, getState} = store;
Client4.serverVersion = '';
Client.serverVersion = '';
Client.token = null;
PushNotifications.cancelAllLocalNotifications();
setServerVersion('')(dispatch, getState);
this.startApp('fade');
@@ -176,8 +184,16 @@ export default class Mattermost {
};
onRegisterDevice = (data) => {
const prefix = Platform.OS === 'ios' ? General.PUSH_NOTIFY_APPLE_REACT_NATIVE : General.PUSH_NOTIFY_ANDROID_REACT_NATIVE;
const {dispatch, getState} = store;
let prefix;
if (Platform.OS === 'ios') {
prefix = General.PUSH_NOTIFY_APPLE_REACT_NATIVE;
if (DeviceInfo.getBundleId().includes('rnbeta')) {
prefix = `${prefix}beta`;
}
} else {
prefix = General.PUSH_NOTIFY_ANDROID_REACT_NATIVE;
}
setDeviceToken(`${prefix}:${data.token}`)(dispatch, getState);
this.isConfigured = true;
};

View File

@@ -181,15 +181,6 @@ function loading(state = false, action) {
}
}
function refreshing(state = false, action) {
switch (action.type) {
case ViewTypes.SET_CHANNEL_REFRESHING:
return action.refreshing;
default:
return state;
}
}
function tooltipVisible(state = false, action) {
switch (action.type) {
case ViewTypes.POST_TOOLTIP_VISIBLE:
@@ -245,7 +236,6 @@ export default combineReducers({
displayName,
drafts,
loading,
refreshing,
tooltipVisible,
postVisibility,
loadingPosts

View File

@@ -10,6 +10,7 @@ import i18n from './i18n';
import login from './login';
import notification from './notification';
import root from './root';
import search from './search';
import selectServer from './select_server';
import team from './team';
import thread from './thread';
@@ -22,6 +23,7 @@ export default combineReducers({
login,
notification,
root,
search,
selectServer,
team,
thread

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {ViewTypes} from 'app/constants';
import {UserTypes} from 'mattermost-redux/action_types';
export default function search(state = '', action) {
switch (action.type) {
case ViewTypes.SEARCH_DRAFT_CHANGED: {
return action.text;
}
case UserTypes.LOGOUT_SUCCESS:
return '';
default:
return state;
}
}

View File

@@ -18,6 +18,7 @@ import StatusBar from 'app/components/status_bar';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import MattermostIcon from 'app/components/mattermost_icon';
import Config from 'assets/config';
export default class About extends PureComponent {
static propTypes = {
@@ -34,6 +35,14 @@ export default class About extends PureComponent {
Linking.openURL('http://about.mattermost.com/');
};
handlePlatformNotice = () => {
Linking.openURL(Config.PlatformNoticeURL);
};
handleMobileNotice = () => {
Linking.openURL(Config.MobileNoticeURL);
};
render() {
const {theme, config, license} = this.props;
const style = getStyleSheet(theme);
@@ -206,6 +215,33 @@ export default class About extends PureComponent {
currentYear: new Date().getFullYear()
}}
/>
<View style={style.noticeContainer}>
<View style={style.footerGroup}>
<FormattedText
id='mobile.notice_text'
defaultMessage='Mattermost is made possible by the open source software used in our {platform} and {mobile}.'
style={style.footerText}
values={{
platform: (
<FormattedText
id='mobile.notice_platform_link'
defaultMessage='platform'
style={style.noticeLink}
onPress={this.handlePlatformNotice}
/>
),
mobile: (
<FormattedText
id='mobile.notice_mobile_link'
defaultMessage='mobile apps'
style={[style.noticeLink, {marginLeft: 5}]}
onPress={this.handleMobileNotice}
/>
)
}}
/>
</View>
</View>
<View style={style.hashContainer}>
<View style={style.footerGroup}>
<FormattedText
@@ -306,6 +342,16 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
flexDirection: 'row',
marginVertical: 20
},
noticeContainer: {
flex: 1,
flexDirection: 'column',
marginTop: 10
},
noticeLink: {
color: theme.linkColor,
fontSize: 11,
lineHeight: 13
},
hashContainer: {
flex: 1,
flexDirection: 'column',

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {injectIntl, intlShape} from 'react-intl';
import {
Alert,
TouchableOpacity,
StyleSheet,
View
} from 'react-native';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import FormattedText from 'app/components/formatted_text';
import StatusBar from 'app/components/status_bar';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
class AdvancedSettings extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
purgeOfflineStore: PropTypes.func.isRequired
}).isRequired,
intl: intlShape.isRequired,
theme: PropTypes.object
};
buildItemRow = (icon, id, defaultMessage, action, separator = true, nextArrow = false) => {
const {theme} = this.props;
const style = getStyleSheet(theme);
return (
<View
key={id}
style={style.itemWrapper}
>
<TouchableOpacity
style={style.item}
onPress={() => this.handlePress(action)}
>
<View style={style.itemLeftIconContainer}>
<MaterialIcon
name={icon}
size={18}
style={style.itemLeftIcon}
/>
</View>
<FormattedText
id={id}
defaultMessage={defaultMessage}
style={style.itemText}
/>
{nextArrow &&
<MaterialIcon
name='angle-right'
size={18}
style={style.itemRightIcon}
/>
}
</TouchableOpacity>
{separator && <View style={style.separator}/>}
</View>
);
};
clearOfflineCache = () => {
const {actions, intl} = this.props;
Alert.alert(
intl.formatMessage({id: 'mobile.advanced_settings.reset_title', defaultMessage: 'Reset Cache'}),
intl.formatMessage({id: 'mobile.advanced_settings.reset_message', defaultMessage: '\nThis will reset all offline data and restart the app. You will be automatically logged back in once the app restarts.\n'}),
[{
text: intl.formatMessage({id: 'mobile.advanced_settings.reset_button', defaultMessage: 'Reset'}),
onPress: () => actions.purgeOfflineStore()
}, {
text: intl.formatMessage({id: 'channel_modal.cancel', defaultMessage: 'Cancel'}),
onPress: () => true
}]
);
};
handlePress = (action) => {
preventDoubleTap(action, this);
};
renderItems = () => {
return [
this.buildItemRow('storage', 'mobile.advanced_settings.reset_title', 'Reset Cache', this.clearOfflineCache, false, false)
];
};
render() {
const {theme} = this.props;
const style = getStyleSheet(theme);
return (
<View style={style.wrapper}>
<StatusBar/>
<View style={style.container}>
<View style={style.itemsContainer}>
{this.renderItems()}
</View>
</View>
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.03)
},
item: {
height: 45,
flexDirection: 'row',
alignItems: 'center'
},
itemLeftIcon: {
color: changeOpacity(theme.centerChannelColor, 0.5)
},
itemLeftIconContainer: {
width: 18,
marginRight: 15,
alignItems: 'center',
justifyContent: 'center'
},
itemText: {
fontSize: 16,
color: theme.centerChannelColor,
flex: 1
},
itemRightIcon: {
color: changeOpacity(theme.centerChannelColor, 0.5)
},
itemsContainer: {
marginTop: 30,
backgroundColor: theme.centerChannelBg,
borderTopWidth: 1,
borderBottomWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.1),
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1)
},
itemWrapper: {
marginHorizontal: 15
},
separator: {
height: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1)
},
wrapper: {
flex: 1,
backgroundColor: theme.centerChannelBg
}
});
});
export default injectIntl(AdvancedSettings);

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {purgeOfflineStore} from 'app/actions/views/root';
import {getTheme} from 'app/selectors/preferences';
import AdvancedSettings from './advanced_settings';
function mapStateToProps(state, ownProps) {
return {
...ownProps,
theme: getTheme(state)
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
purgeOfflineStore
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(AdvancedSettings);

View File

@@ -28,6 +28,7 @@ import {makeStyleSheetFromTheme} from 'app/utils/theme';
import ChannelDrawerButton from './channel_drawer_button';
import ChannelTitle from './channel_title';
import ChannelPostList from './channel_post_list';
import ChannelSearchButton from './channel_search_button';
class Channel extends PureComponent {
static propTypes = {
@@ -239,6 +240,7 @@ class Channel extends PureComponent {
<ChannelTitle
onPress={() => preventDoubleTap(this.goToChannelInfo, this)}
/>
<ChannelSearchButton navigator={navigator}/>
</View>
<OfflineIndicator/>
</View>

View File

@@ -154,6 +154,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
flexDirection: 'column',
justifyContent: 'center',
paddingHorizontal: 10,
paddingTop: 5,
zIndex: 30
},
badge: {

View File

@@ -8,12 +8,14 @@ import {
Animated,
Dimensions,
Platform,
StyleSheet,
View
} from 'react-native';
import {General} from 'mattermost-redux/constants';
import ChannelLoader from 'app/components/channel_loader';
import FormattedText from 'app/components/formatted_text';
import PostList from 'app/components/post_list';
import PostListRetry from 'app/components/post_list_retry';
@@ -23,13 +25,16 @@ const {height: deviceHeight, width: deviceWidth} = Dimensions.get('window');
class ChannelPostList extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
loadPostsIfNecessary: PropTypes.func.isRequired,
loadPostsIfNecessaryWithRetry: PropTypes.func.isRequired,
loadThreadIfNecessary: PropTypes.func.isRequired,
increasePostVisibility: PropTypes.func.isRequired,
selectPost: PropTypes.func.isRequired
selectPost: PropTypes.func.isRequired,
refreshChannelWithRetry: PropTypes.func.isRequired
}).isRequired,
channel: PropTypes.object.isRequired,
channelIsLoading: PropTypes.bool,
channelIsRefreshing: PropTypes.bool,
channelRefreshingFailed: PropTypes.bool,
currentChannelId: PropTypes.string,
intl: intlShape.isRequired,
loadingPosts: PropTypes.bool,
@@ -50,28 +55,44 @@ class ChannelPostList extends PureComponent {
super(props);
this.state = {
loaderOpacity: new Animated.Value(1)
loaderOpacity: new Animated.Value(1),
retryMessageHeight: new Animated.Value(0)
};
}
componentDidMount() {
const {channel, posts, channelRefreshingFailed} = this.props;
this.mounted = true;
this.loadPosts(this.props.channel.id);
this.shouldMarkChannelAsLoaded(posts.length, channel.total_msg_count === 0, channelRefreshingFailed);
}
componentWillReceiveProps(nextProps) {
const {currentChannelId, channel: currentChannel} = this.props;
const {currentChannelId: nextChannelId, channel: nextChannel, channelRefreshingFailed: nextChannelRefreshingFailed, posts: nextPosts} = nextProps;
// Show the loader if the channel id change
if (this.props.currentChannelId !== nextProps.currentChannelId) {
if (currentChannelId !== nextChannelId) {
this.setState({
loaderOpacity: new Animated.Value(1)
}, () => {
this.shouldMarkChannelAsLoaded(nextPosts.length, nextChannel.total_msg_count === 0, nextChannelRefreshingFailed);
});
}
if (this.props.channel.id !== nextProps.channel.id) {
if (currentChannel.id !== nextChannel.id) {
// Load the posts when the channel actually changes
this.loadPosts(nextProps.channel.id);
this.loadPosts(nextChannel.id);
}
if (nextChannelRefreshingFailed && this.state.channelLoaded && nextPosts.length) {
this.toggleRetryMessage();
} else if (!nextChannelRefreshingFailed || !nextPosts.length) {
this.toggleRetryMessage(false);
}
this.shouldMarkChannelAsLoaded(nextPosts.length, nextChannel.total_msg_count === 0, nextChannelRefreshingFailed);
const showLoadMore = nextProps.posts.length >= nextProps.postVisibility;
this.setState({
showLoadMore
@@ -82,6 +103,12 @@ class ChannelPostList extends PureComponent {
this.mounted = false;
}
shouldMarkChannelAsLoaded = (postsCount, channelHasMessages, channelRefreshingFailed) => {
if (postsCount || channelHasMessages || channelRefreshingFailed) {
this.channelLoaded();
}
};
channelLoaded = () => {
Animated.timing(this.state.loaderOpacity, {
toValue: 0,
@@ -93,11 +120,20 @@ class ChannelPostList extends PureComponent {
});
};
toggleRetryMessage = (show = true) => {
const value = show ? 38 : 0;
Animated.timing(this.state.retryMessageHeight, {
toValue: value,
duration: 350
}).start();
};
goToThread = (post) => {
const {actions, channel, intl, navigator, theme} = this.props;
const channelId = post.channel_id;
const rootId = (post.root_id || post.id);
actions.loadThreadIfNecessary(post.root_id);
actions.selectPost(rootId);
let title;
@@ -139,10 +175,9 @@ class ChannelPostList extends PureComponent {
}
};
loadPosts = async (channelId) => {
loadPosts = (channelId) => {
this.setState({channelLoaded: false});
await this.props.actions.loadPostsIfNecessary(channelId);
this.channelLoaded();
this.props.actions.loadPostsIfNecessaryWithRetry(channelId);
};
render() {
@@ -150,27 +185,26 @@ class ChannelPostList extends PureComponent {
channel,
channelIsLoading,
channelIsRefreshing,
channelRefreshingFailed,
loadingPosts,
myMember,
navigator,
networkOnline,
posts,
postVisibility,
theme
} = this.props;
const {channelLoaded, loaderOpacity} = this.state;
const {loaderOpacity, retryMessageHeight} = this.state;
let component;
if (!posts.length && channel.total_msg_count > 0 && (!channelIsLoading || !networkOnline)) {
// If no posts has been loaded and we are offline
if (!posts.length && channelRefreshingFailed) {
component = (
<PostListRetry
retry={() => this.loadPosts(channel.id)}
theme={theme}
/>
);
} else if ((channelIsLoading || !channelLoaded) && !channelIsRefreshing && !loadingPosts) {
} else if (channelIsLoading) {
component = <ChannelLoader theme={theme}/>;
} else {
component = (
@@ -186,6 +220,8 @@ class ChannelPostList extends PureComponent {
lastViewedAt={myMember.last_viewed_at}
channel={channel}
navigator={navigator}
refreshing={channelIsRefreshing}
channelIsLoading={channelIsLoading}
/>
);
}
@@ -206,9 +242,28 @@ class ChannelPostList extends PureComponent {
>
<ChannelLoader theme={theme}/>
</AnimatedView>
<AnimatedView style={[style.refreshIndicator, {height: retryMessageHeight}]}>
<FormattedText
id='mobile.retry_message'
defaultMessage='Refreshing messages failed. Pull up to try again.'
style={{color: 'white', flex: 1, fontSize: 12}}
/>
</AnimatedView>
</View>
);
}
}
const style = StyleSheet.create({
refreshIndicator: {
alignItems: 'center',
backgroundColor: '#fb8000',
flexDirection: 'row',
paddingHorizontal: 10,
position: 'absolute',
top: 0,
width: deviceWidth
}
});
export default injectIntl(ChannelPostList);

View File

@@ -8,8 +8,7 @@ import {selectPost} from 'mattermost-redux/actions/posts';
import {RequestStatus} from 'mattermost-redux/constants';
import {makeGetPostsInChannel} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentChannelId, getMyCurrentChannelMembership} from 'mattermost-redux/selectors/entities/channels';
import {loadPostsIfNecessary, increasePostVisibility} from 'app/actions/views/channel';
import {loadPostsIfNecessaryWithRetry, loadThreadIfNecessary, increasePostVisibility, refreshChannelWithRetry} from 'app/actions/views/channel';
import {getTheme} from 'app/selectors/preferences';
import ChannelPostList from './channel_post_list';
@@ -19,19 +18,36 @@ function makeMapStateToProps() {
return function mapStateToProps(state, ownProps) {
const channelId = ownProps.channel.id;
const {refreshing} = state.views.channel;
const {getPosts} = state.requests.posts;
const {getPosts, getPostsRetryAttempts, getPostsSince, getPostsSinceRetryAttempts} = state.requests.posts;
const posts = getPostsInChannel(state, channelId) || [];
const {websocket: websocketRequest} = state.requests.general;
const {connection} = state.views;
const networkOnline = connection && websocketRequest.status === RequestStatus.SUCCESS;
let getPostsStatus;
if (getPostsRetryAttempts > 0) {
getPostsStatus = getPosts.status;
} else if (getPostsSinceRetryAttempts > 1) {
getPostsStatus = getPostsSince.status;
}
let channelIsRefreshing = getPostsStatus === RequestStatus.STARTED;
let channelRefreshingFailed = getPostsStatus === RequestStatus.FAILURE;
if (!networkOnline) {
channelIsRefreshing = false;
channelRefreshingFailed = true;
}
return {
channelIsLoading: (getPosts.status === RequestStatus.STARTED),
channelIsRefreshing: refreshing,
channelIsLoading: state.views.channel.loading,
channelIsRefreshing,
channelRefreshingFailed,
currentChannelId: getCurrentChannelId(state),
posts,
postVisibility: state.views.channel.postVisibility[channelId],
loadingPosts: state.views.channel.loadingPosts[channelId],
myMember: getMyCurrentChannelMembership(state),
networkOnline: state.offline.online,
networkOnline,
theme: getTheme(state),
...ownProps
};
@@ -41,9 +57,11 @@ function makeMapStateToProps() {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
loadPostsIfNecessary,
loadPostsIfNecessaryWithRetry,
loadThreadIfNecessary,
increasePostVisibility,
selectPost
selectPost,
refreshChannelWithRetry
}, dispatch)
};
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {
StyleSheet,
TouchableOpacity,
View
} from 'react-native';
import AwesomeIcon from 'react-native-vector-icons/FontAwesome';
import {clearSearch} from 'mattermost-redux/actions/search';
import {handlePostDraftChanged} from 'app/actions/views/channel';
import {getTheme} from 'app/selectors/preferences';
import {preventDoubleTap} from 'app/utils/tap';
const SEARCH = 'search';
class ChannelSearchButton extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
clearSearch: PropTypes.func.isRequired,
handlePostDraftChanged: PropTypes.func.isRequired
}).isRequired,
applicationInitializing: PropTypes.bool.isRequired,
navigator: PropTypes.object,
theme: PropTypes.object
};
handlePress = async () => {
const {actions, navigator, theme} = this.props;
await actions.clearSearch();
actions.handlePostDraftChanged(SEARCH, '');
navigator.showModal({
screen: 'Search',
animated: true,
backButtonTitle: '',
overrideBackPress: true,
navigatorStyle: {
navBarHidden: true,
screenBackgroundColor: theme.centerChannelBg
},
passProps: {
theme
}
});
};
render() {
const {
applicationInitializing,
theme
} = this.props;
if (applicationInitializing) {
return null;
}
return (
<TouchableOpacity
onPress={() => preventDoubleTap(this.handlePress, this)}
style={style.container}
>
<View style={style.wrapper}>
<AwesomeIcon
name='search'
size={18}
color={theme.sidebarHeaderTextColor}
/>
</View>
</TouchableOpacity>
);
}
}
const style = StyleSheet.create({
container: {
width: 40
},
wrapper: {
alignItems: 'center',
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
paddingHorizontal: 10,
zIndex: 30
}
});
function mapStateToProps(state, ownProps) {
return {
applicationInitializing: state.views.root.appInitializing,
theme: getTheme(state),
...ownProps
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
clearSearch,
handlePostDraftChanged
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ChannelSearchButton);

View File

@@ -35,7 +35,7 @@ function ChannelTitle(props) {
return (
<TouchableOpacity
style={{flexDirection: 'row', flex: 1, marginRight: 40}}
style={{flexDirection: 'row', flex: 1}}
onPress={props.onPress}
>
<View style={{flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginHorizontal: 15}}>

View File

@@ -12,10 +12,11 @@ import {
} from 'react-native';
import Loading from 'app/components/loading';
import MemberList from 'app/components/custom_list';
import CustomList from 'app/components/custom_list';
import UserListRow from 'app/components/custom_list/user_list_row';
import SearchBar from 'app/components/search_bar';
import StatusBar from 'app/components/status_bar';
import {createMembersSections, loadingText, markSelectedProfiles, renderMemberRow} from 'app/utils/member_list';
import {createMembersSections, loadingText, markSelectedProfiles} from 'app/utils/member_list';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {General, RequestStatus} from 'mattermost-redux/constants';
@@ -177,8 +178,7 @@ class ChannelAddMembers extends PureComponent {
let {page} = this.state;
if (loadMoreRequestStatus !== RequestStatus.STARTED && next && !searching) {
page = page + 1;
actions.getProfilesNotInChannel(currentTeam.id, currentChannel.id, page, General.PROFILE_CHUNK_SIZE).
then((data) => {
actions.getProfilesNotInChannel(currentTeam.id, currentChannel.id, page, General.PROFILE_CHUNK_SIZE).then((data) => {
if (data && data.length) {
this.setState({
page
@@ -192,7 +192,7 @@ class ChannelAddMembers extends PureComponent {
onNavigatorEvent = (event) => {
if (event.type === 'NavBarButtonPress') {
if (event.id === 'add-members') {
if (event.id === this.addButton.id) {
this.handleAddMembersPress();
}
}
@@ -259,7 +259,7 @@ class ChannelAddMembers extends PureComponent {
value={term}
/>
</View>
<MemberList
<CustomList
data={profiles}
theme={theme}
searching={searching}
@@ -270,7 +270,7 @@ class ChannelAddMembers extends PureComponent {
loadingText={loadingText}
selectable={this.state.canSelect}
onRowSelect={this.handleRowSelect}
renderRow={renderMemberRow}
renderRow={UserListRow}
createSections={createMembersSections}
/>
</View>

View File

@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
import {injectIntl, intlShape} from 'react-intl';
import {
Alert,
Platform,
ScrollView,
StyleSheet,
View
@@ -67,7 +68,11 @@ class ChannelInfo extends PureComponent {
close = () => {
EventEmitter.emit(General.DEFAULT_CHANNEL, '');
this.props.navigator.pop({animated: true});
if (Platform.OS === 'android') {
this.props.navigator.dismissModal({animated: true});
} else {
this.props.navigator.pop({animated: true});
}
};
goToChannelAddMembers = () => {
@@ -228,6 +233,7 @@ class ChannelInfo extends PureComponent {
currentChannelCreatorName,
currentChannelMemberCount,
canManageUsers,
navigator,
status,
theme
} = this.props;
@@ -259,6 +265,8 @@ class ChannelInfo extends PureComponent {
creator={currentChannelCreatorName}
displayName={currentChannel.display_name}
header={currentChannel.header}
memberCount={currentChannelMemberCount}
navigator={navigator}
purpose={currentChannel.purpose}
status={status}
theme={theme}
@@ -313,7 +321,7 @@ class ChannelInfo extends PureComponent {
/>
</View>
}
{this.renderLeaveOrDeleteChannelRow() && currentChannelMemberCount > 1 &&
{this.renderLeaveOrDeleteChannelRow() &&
<View>
<View style={style.separator}/>
<ChannelInfoRow

View File

@@ -23,14 +23,26 @@ export default class ChannelInfoHeader extends React.PureComponent {
memberCount: PropTypes.number,
displayName: PropTypes.string.isRequired,
header: PropTypes.string,
navigator: PropTypes.object.isRequired,
purpose: PropTypes.string,
status: PropTypes.string,
theme: PropTypes.object.isRequired,
type: PropTypes.string.isRequired
}
};
render() {
const {createAt, creator, displayName, header, memberCount, purpose, status, theme, type} = this.props;
const {
createAt,
creator,
displayName,
header,
memberCount,
navigator,
purpose,
status,
theme,
type
} = this.props;
const style = getStyleSheet(theme);
const textStyles = getMarkdownTextStyles(theme);
@@ -63,6 +75,7 @@ export default class ChannelInfoHeader extends React.PureComponent {
defaultMessage='Purpose'
/>
<Markdown
navigator={navigator}
baseTextStyle={style.detail}
textStyles={textStyles}
blockStyles={blockStyles}
@@ -78,6 +91,7 @@ export default class ChannelInfoHeader extends React.PureComponent {
defaultMessage='Header'
/>
<Markdown
navigator={navigator}
baseTextStyle={style.detail}
textStyles={textStyles}
blockStyles={blockStyles}

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