Compare commits

..

9 Commits

Author SHA1 Message Date
Elias Nahum
b4f474a74e Include build folders in make clean 2017-07-19 20:20:27 -04:00
Elias Nahum
50ce8b6974 Update fastlane 2017-07-19 18:40:07 -04:00
Elias Nahum
910b67d47a Version bump to 41 2017-07-19 18:39:51 -04:00
Elias Nahum
85b7f3703f Make release 1.0.1 to use com.mattermost.rnbeta 2017-07-19 17:13:35 -04:00
enahum
da3a0a22b2 Fix SSO login (#754)
* Fix SSO login

* Update style

* Fix stripTrailingSlashes
2017-07-19 15:33:41 -04:00
enahum
408d184dea RN-261 Fix app crashing when badge is unmounted before it displays (#743) 2017-07-18 13:26:10 -04:00
enahum
03488f6010 fix for huawei not rendering posts (#751) 2017-07-17 16:02:40 -04:00
Elias Nahum
b0ff189147 Set build number 2017-07-17 16:00:14 -04:00
Elias Nahum
0bd69fcd6b Prepare for dot release 1.0.1 2017-07-17 12:45:58 -04:00
489 changed files with 12975 additions and 47016 deletions

View File

@@ -1,10 +1,5 @@
{
"presets": [ "react-native" ],
"env": {
"production": {
"plugins": ["transform-remove-console"]
}
},
"plugins": [
["module-resolver", {
"root": ["./src", "."],

View File

@@ -1,48 +1,58 @@
[ignore]
; We fork some components by platform
# We fork some components by platform.
.*/*[.]android.js
; Ignore "BUCK" generated dirs
# Ignore templates with `@flow` in header
.*/local-cli/generator.*
# Ignore malformed json
.*/node_modules/y18n/test/.*\.json
# Ignore the website subdir
<PROJECT_ROOT>/website/.*
# Ignore BUCK generated dirs
<PROJECT_ROOT>/\.buckd/
; Ignore unexpected extra "@providesModule"
.*/node_modules/.*/node_modules/fbjs/.*
# Ignore unexpected extra @providesModule
.*/node_modules/commoner/test/source/widget/share.js
; Ignore duplicate module providers
; For RN Apps installed via npm, "Libraries" folder is inside
; "node_modules/react-native" but in the source repo it is in the root
# Ignore duplicate module providers
# For RN Apps installed via npm, "Libraries" folder is inside node_modules/react-native but in the source repo it is in the root
.*/Libraries/react-native/React.js
; Ignore polyfills
.*/Libraries/polyfills/.*
.*/Libraries/react-native/ReactNative.js
.*/node_modules/jest-runtime/build/__tests__/.*
[include]
[libs]
node_modules/react-native/Libraries/react-native/react-native-interface.js
node_modules/react-native/flow/
node_modules/react-native/flow
flow/
[options]
emoji=true
module.system=haste
esproposal.class_static_fields=enable
esproposal.class_instance_fields=enable
experimental.strict_type_args=true
munge_underscores=true
module.name_mapper='^image![a-zA-Z0-9$_-]+$' -> 'GlobalImageStub'
module.name_mapper='^[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> 'RelativeImageStub'
suppress_type=$FlowIssue
suppress_type=$FlowFixMe
suppress_type=$FlowFixMeProps
suppress_type=$FlowFixMeState
suppress_type=$FixMe
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(>=0\\.\\(5[0-3]\\|[1-4][0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(>=0\\.\\(5[0-3]\\|[1-4][0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)?:? #[0-9]+
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(>=0\\.\\(3[0-2]\\|[1-2][0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(>=0\\.\\(3[0-2]\\|1[0-9]\\|[1-2][0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)?:? #[0-9]+
suppress_comment=\\(.\\|\n\\)*\\$FlowFixedInNextDeploy
suppress_comment=\\(.\\|\n\\)*\\$FlowExpectedError
unsafe.enable_getters_and_setters=true
[version]
^0.53.0
^0.32.0

32
.gitignore vendored
View File

@@ -1,8 +1,5 @@
assets/override
dist
*.zip
server.PID
mattermost.keystore
# OSX
#
@@ -29,27 +26,28 @@ DerivedData
*.xcuserstate
project.xcworkspace
# Android/IntelliJ
# Android/IJ
#
build/
*.iml
.idea
.gradle
local.properties
*.iml
# node.js
#
node_modules/
npm-debug.log
.npminstall
yarn-error.log
# yarn
#
.yarninstall
# BUCK
buck-out/
\.buckd/
android/app/libs
*.keystore
android/keystores/debug.keystore
# Vim
[._]*.s[a-w][a-z]
@@ -60,25 +58,13 @@ Session.vim
*~
tags
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/
fastlane/report.xml
fastlane/Preview.html
*/fastlane/screenshots
*.zip
server.PID
mattermost.keystore
# Sentry
android/sentry.properties
ios/sentry.properties
# Testing
.nyc_output
# Pods
.podinstall
ios/Pods/

View File

@@ -1,154 +1,13 @@
# Mattermost Mobile Apps Changelog
## v1.4.1 Release
Release Date: Nov 15, 2017
Server Versions Supported: Server v4.0+ is required, Self-Signed SSL Certificates are not supported
### Bug Fixes
- Fixed network detection issue causing some people to be unable to access the app
- Fixed issue with lag when pressing send button
- Fixed app crash when opening notification settings
- Fixed various other bugs to reduce app crashes
## v1.4 Release
- Release Date: November 6, 2017
- Server Versions Supported: Server v4.0+ is required, Self-Signed SSL Certificates are not supported
### Highlights
#### Performance improvements
- Various performance improvements to decrease channel load times
### Bug Fixes
- Fixed issue with Android app sometimes showing a white screen when re-opening the app
- Fixed an issue with orientation lock not working on Android
## v1.3 Release
- Release Date: October 5, 2017
- Server Versions Supported: Server v4.0+ is required, Self-Signed SSL Certificates are not supported
### Highlights
#### Tablet Support (Beta)
- Added support for landscape view, so the app may be used on tablets
- Note: Tablet support is in beta, and further improvements are planned for a later date
#### Link Previews
- Added support for image, GIF, and youtube link previews
#### Notifications
- Android: Added the ability to set light, vibrate, and sound settings
- Android: Improved notification stacking so most recent notification shows first
- Updated the design for Notification settings to improve usability
- Added the ability to reply from a push notification without opening the app (requires Android v7.0+, iOS 10+)
- Increased speed when opening app from a push notification
#### Download Files
- Added the ability to download all files on Android and images on iOS
### Improvements
- Using `+` shortcut for emoji reactions is now supported
- Improved emoji formatting (alignment and rendering of non-square aspect ratios)
- Added support for error tracking with Sentry
- Only show the "Connecting..." bar after two connection attempts
### Bug Fixes
- Fixed link rendering not working in certain cases
- Fixed theme color issue with status bar on Android
## v1.2 Release
- Release Date: September 5, 2017
- Server Versions Supported: Server v4.0+ is required, Self-Signed SSL Certificates are not supported
### Highlights
#### AppConfig Support for EMM solutions
- Added [AppConfig](https://www.appconfig.org/) support, to make it easier to integrate with a variety of EMM solutions
#### Code block viewer
- Tap on a code block to open a viewer for easier reading
### Improvements
- Updated formatting for markdown lists and code blocks
- Updated formatting for `in:` and `from:` search autocomplete
### Emoji Picker for Emoji Reactions
- Added an emoji picker for selecting a reaction
### Bug Fixes
- Fixed issue where if only LDAP and GitLab login were enabled, LDAP did not show up on the login page
- Fixed issue with 3 digit mention count UI in channel drawer
### Known Issues
- Using `+:emoji:` to react to a message is not yet supported
## v1.1 Release
- Release Date: August 2017
- Server Versions Supported: Server v3.10+ is required, Self-Signed SSL Certificates are not 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
- Release Date: July 10, 2017
- Server Versions Supported: Server v3.8+ is required, Self-Signed SSL Certificates are not supported
- Planned Release Date: July 5, 2017
- Server Versions Supported: Server v3.8+ is required, Self-Signed SSL Certificates are not yet supported
### Highlights
#### Authentication (Requires v3.10+ [Mattermost server](https://github.com/mattermost/platform))
#### Authentication
- GitLab login
#### Offline Support

View File

@@ -1,729 +0,0 @@
package com.imagepicker;
import android.Manifest;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.ResolveInfo;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Build;
import android.provider.MediaStore;
import android.provider.Settings;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.StyleRes;
import android.support.v4.app.ActivityCompat;
import android.support.v7.app.AlertDialog;
import android.text.TextUtils;
import android.util.Base64;
import android.util.Patterns;
import android.webkit.MimeTypeMap;
import android.content.pm.PackageManager;
import com.facebook.react.ReactActivity;
import com.facebook.react.bridge.ActivityEventListener;
import com.facebook.react.bridge.Callback;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.imagepicker.media.ImageConfig;
import com.imagepicker.permissions.PermissionUtils;
import com.imagepicker.permissions.OnImagePickerPermissionsCallback;
import com.imagepicker.utils.MediaUtils.ReadExifResult;
import com.imagepicker.utils.RealPathUtil;
import com.imagepicker.utils.UI;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.ref.WeakReference;
import java.util.List;
import com.facebook.react.modules.core.PermissionListener;
import com.facebook.react.modules.core.PermissionAwareActivity;
import static com.imagepicker.utils.MediaUtils.*;
import static com.imagepicker.utils.MediaUtils.createNewFile;
import static com.imagepicker.utils.MediaUtils.getResizedImage;
public class ImagePickerModule extends ReactContextBaseJavaModule
implements ActivityEventListener
{
public static final int REQUEST_LAUNCH_IMAGE_CAPTURE = 13001;
public static final int REQUEST_LAUNCH_IMAGE_LIBRARY = 13002;
public static final int REQUEST_LAUNCH_VIDEO_LIBRARY = 13003;
public static final int REQUEST_LAUNCH_VIDEO_CAPTURE = 13004;
public static final int REQUEST_PERMISSIONS_FOR_CAMERA = 14001;
public static final int REQUEST_PERMISSIONS_FOR_LIBRARY = 14002;
private final ReactApplicationContext reactContext;
private final int dialogThemeId;
protected Callback callback;
private ReadableMap options;
protected Uri cameraCaptureURI;
private Boolean noData = false;
private Boolean pickVideo = false;
private ImageConfig imageConfig = new ImageConfig(null, null, 0, 0, 100, 0, false);
@Deprecated
private int videoQuality = 1;
@Deprecated
private int videoDurationLimit = 0;
private ResponseHelper responseHelper = new ResponseHelper();
private PermissionListener listener = new PermissionListener()
{
public boolean onRequestPermissionsResult(final int requestCode,
@NonNull final String[] permissions,
@NonNull final int[] grantResults)
{
boolean permissionsGranted = true;
for (int i = 0; i < permissions.length; i++)
{
final boolean granted = grantResults[i] == PackageManager.PERMISSION_GRANTED;
permissionsGranted = permissionsGranted && granted;
}
if (callback == null || options == null)
{
return false;
}
if (!permissionsGranted)
{
responseHelper.invokeError(callback, "Permissions weren't granted");
return false;
}
switch (requestCode)
{
case REQUEST_PERMISSIONS_FOR_CAMERA:
launchCamera(options, callback);
break;
case REQUEST_PERMISSIONS_FOR_LIBRARY:
launchImageLibrary(options, callback);
break;
}
return true;
}
};
public ImagePickerModule(ReactApplicationContext reactContext,
@StyleRes final int dialogThemeId)
{
super(reactContext);
this.dialogThemeId = dialogThemeId;
this.reactContext = reactContext;
this.reactContext.addActivityEventListener(this);
}
@Override
public String getName() {
return "ImagePickerManager";
}
@ReactMethod
public void showImagePicker(final ReadableMap options, final Callback callback) {
Activity currentActivity = getCurrentActivity();
if (currentActivity == null)
{
responseHelper.invokeError(callback, "can't find current Activity");
return;
}
this.callback = callback;
this.options = options;
imageConfig = new ImageConfig(null, null, 0, 0, 100, 0, false);
final AlertDialog dialog = UI.chooseDialog(this, options, new UI.OnAction()
{
@Override
public void onTakePhoto(@NonNull final ImagePickerModule module)
{
if (module == null)
{
return;
}
module.launchCamera();
}
@Override
public void onUseLibrary(@NonNull final ImagePickerModule module)
{
if (module == null)
{
return;
}
module.launchImageLibrary();
}
@Override
public void onCancel(@NonNull final ImagePickerModule module)
{
if (module == null)
{
return;
}
module.doOnCancel();
}
@Override
public void onCustomButton(@NonNull final ImagePickerModule module,
@NonNull final String action)
{
if (module == null)
{
return;
}
module.invokeCustomButton(action);
}
});
dialog.show();
}
public void doOnCancel()
{
if (this.callback != null) {
responseHelper.invokeCancel(this.callback);
}
}
public void launchCamera()
{
this.launchCamera(this.options, this.callback);
}
// NOTE: Currently not reentrant / doesn't support concurrent requests
@ReactMethod
public void launchCamera(final ReadableMap options, final Callback callback)
{
if (!isCameraAvailable())
{
responseHelper.invokeError(callback, "Camera not available");
return;
}
final Activity currentActivity = getCurrentActivity();
if (currentActivity == null)
{
responseHelper.invokeError(callback, "can't find current Activity");
return;
}
this.callback = callback;
this.options = options;
if (!permissionsCheck(currentActivity, callback, REQUEST_PERMISSIONS_FOR_CAMERA))
{
return;
}
parseOptions(this.options);
int requestCode;
Intent cameraIntent;
if (pickVideo)
{
requestCode = REQUEST_LAUNCH_VIDEO_CAPTURE;
cameraIntent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE);
cameraIntent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, videoQuality);
if (videoDurationLimit > 0)
{
cameraIntent.putExtra(MediaStore.EXTRA_DURATION_LIMIT, videoDurationLimit);
}
}
else
{
requestCode = REQUEST_LAUNCH_IMAGE_CAPTURE;
cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
final File original = createNewFile(reactContext, this.options, false);
imageConfig = imageConfig.withOriginalFile(original);
if (imageConfig.original != null) {
cameraCaptureURI = RealPathUtil.compatUriFromFile(reactContext, imageConfig.original);
}else {
responseHelper.invokeError(callback, "Couldn't get file path for photo");
return;
}
if (cameraCaptureURI == null)
{
responseHelper.invokeError(callback, "Couldn't get file path for photo");
return;
}
cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, cameraCaptureURI);
}
if (cameraIntent.resolveActivity(reactContext.getPackageManager()) == null)
{
responseHelper.invokeError(callback, "Cannot launch camera");
return;
}
// Workaround for Android bug.
// grantUriPermission also needed for KITKAT,
// see https://code.google.com/p/android/issues/detail?id=76683
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) {
List<ResolveInfo> resInfoList = reactContext.getPackageManager().queryIntentActivities(cameraIntent, PackageManager.MATCH_DEFAULT_ONLY);
for (ResolveInfo resolveInfo : resInfoList) {
String packageName = resolveInfo.activityInfo.packageName;
reactContext.grantUriPermission(packageName, cameraCaptureURI, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
}
}
try
{
currentActivity.startActivityForResult(cameraIntent, requestCode);
}
catch (ActivityNotFoundException e)
{
e.printStackTrace();
responseHelper.invokeError(callback, "Cannot launch camera");
}
}
public void launchImageLibrary()
{
this.launchImageLibrary(this.options, this.callback);
}
// NOTE: Currently not reentrant / doesn't support concurrent requests
@ReactMethod
public void launchImageLibrary(final ReadableMap options, final Callback callback)
{
final Activity currentActivity = getCurrentActivity();
if (currentActivity == null) {
responseHelper.invokeError(callback, "can't find current Activity");
return;
}
this.options = options;
this.callback = callback;
if (!permissionsCheck(currentActivity, callback, REQUEST_PERMISSIONS_FOR_LIBRARY))
{
return;
}
parseOptions(this.options);
int requestCode;
Intent libraryIntent;
if (pickVideo)
{
requestCode = REQUEST_LAUNCH_VIDEO_LIBRARY;
libraryIntent = new Intent(Intent.ACTION_PICK);
libraryIntent.setType("video/*");
}
else
{
requestCode = REQUEST_LAUNCH_IMAGE_LIBRARY;
libraryIntent = new Intent(Intent.ACTION_PICK,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
}
if (libraryIntent.resolveActivity(reactContext.getPackageManager()) == null)
{
responseHelper.invokeError(callback, "Cannot launch photo library");
return;
}
try
{
currentActivity.startActivityForResult(libraryIntent, requestCode);
}
catch (ActivityNotFoundException e)
{
e.printStackTrace();
responseHelper.invokeError(callback, "Cannot launch photo library");
}
}
@Override
public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
//robustness code
if (passResult(requestCode))
{
return;
}
responseHelper.cleanResponse();
// user cancel
if (resultCode != Activity.RESULT_OK)
{
removeUselessFiles(requestCode, imageConfig);
responseHelper.invokeCancel(callback);
callback = null;
return;
}
Uri uri = null;
switch (requestCode)
{
case REQUEST_LAUNCH_IMAGE_CAPTURE:
uri = cameraCaptureURI;
break;
case REQUEST_LAUNCH_IMAGE_LIBRARY:
uri = data.getData();
String realPath = getRealPathFromURI(uri);
final boolean isUrl = !TextUtils.isEmpty(realPath) &&
Patterns.WEB_URL.matcher(realPath).matches();
if (realPath == null || isUrl)
{
try
{
File file = createFileFromURI(uri);
realPath = file.getAbsolutePath();
uri = Uri.fromFile(file);
}
catch (Exception e)
{
// image not in cache
responseHelper.putString("error", "Could not read photo");
responseHelper.putString("uri", uri.toString());
responseHelper.invokeResponse(callback);
callback = null;
return;
}
}
imageConfig = imageConfig.withOriginalFile(new File(realPath));
break;
case REQUEST_LAUNCH_VIDEO_LIBRARY:
responseHelper.putString("uri", data.getData().toString());
responseHelper.putString("path", getRealPathFromURI(data.getData()));
responseHelper.invokeResponse(callback);
callback = null;
return;
case REQUEST_LAUNCH_VIDEO_CAPTURE:
final String path = getRealPathFromURI(data.getData());
responseHelper.putString("uri", data.getData().toString());
responseHelper.putString("path", path);
fileScan(reactContext, path);
responseHelper.invokeResponse(callback);
callback = null;
return;
}
final ReadExifResult result = readExifInterface(responseHelper, imageConfig);
if (result.error != null)
{
removeUselessFiles(requestCode, imageConfig);
responseHelper.invokeError(callback, result.error.getMessage());
callback = null;
return;
}
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(imageConfig.original.getAbsolutePath(), options);
int initialWidth = options.outWidth;
int initialHeight = options.outHeight;
updatedResultResponse(uri, imageConfig.original.getAbsolutePath());
// don't create a new file if contraint are respected
if (imageConfig.useOriginal(initialWidth, initialHeight, result.currentRotation))
{
responseHelper.putInt("width", initialWidth);
responseHelper.putInt("height", initialHeight);
fileScan(reactContext, imageConfig.original.getAbsolutePath());
}
else
{
imageConfig = getResizedImage(reactContext, this.options, imageConfig, initialWidth, initialHeight, requestCode);
if (imageConfig.resized == null)
{
removeUselessFiles(requestCode, imageConfig);
responseHelper.putString("error", "Can't resize the image");
}
else
{
uri = Uri.fromFile(imageConfig.resized);
BitmapFactory.decodeFile(imageConfig.resized.getAbsolutePath(), options);
responseHelper.putInt("width", options.outWidth);
responseHelper.putInt("height", options.outHeight);
updatedResultResponse(uri, imageConfig.resized.getAbsolutePath());
fileScan(reactContext, imageConfig.resized.getAbsolutePath());
}
}
if (imageConfig.saveToCameraRoll && requestCode == REQUEST_LAUNCH_IMAGE_CAPTURE)
{
final RolloutPhotoResult rolloutResult = rolloutPhotoFromCamera(imageConfig);
if (rolloutResult.error == null)
{
imageConfig = rolloutResult.imageConfig;
uri = Uri.fromFile(imageConfig.getActualFile());
updatedResultResponse(uri, imageConfig.getActualFile().getAbsolutePath());
}
else
{
removeUselessFiles(requestCode, imageConfig);
final String errorMessage = new StringBuilder("Error moving image to camera roll: ")
.append(rolloutResult.error.getMessage()).toString();
responseHelper.putString("error", errorMessage);
return;
}
}
responseHelper.invokeResponse(callback);
callback = null;
this.options = null;
}
public void invokeCustomButton(@NonNull final String action)
{
responseHelper.invokeCustomButton(this.callback, action);
}
@Override
public void onNewIntent(Intent intent) { }
public Context getContext()
{
return getReactApplicationContext();
}
public @StyleRes int getDialogThemeId()
{
return this.dialogThemeId;
}
public @NonNull Activity getActivity()
{
return getCurrentActivity();
}
private boolean passResult(int requestCode)
{
return callback == null || (cameraCaptureURI == null && requestCode == REQUEST_LAUNCH_IMAGE_CAPTURE)
|| (requestCode != REQUEST_LAUNCH_IMAGE_CAPTURE && requestCode != REQUEST_LAUNCH_IMAGE_LIBRARY
&& requestCode != REQUEST_LAUNCH_VIDEO_LIBRARY && requestCode != REQUEST_LAUNCH_VIDEO_CAPTURE);
}
private void updatedResultResponse(@Nullable final Uri uri,
@NonNull final String path)
{
responseHelper.putString("uri", uri.toString());
responseHelper.putString("path", path);
if (!noData) {
responseHelper.putString("data", getBase64StringFromFile(path));
}
putExtraFileInfo(path, responseHelper);
}
private boolean permissionsCheck(@NonNull final Activity activity,
@NonNull final Callback callback,
@NonNull final int requestCode)
{
final int writePermission = ActivityCompat
.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
final int cameraPermission = ActivityCompat
.checkSelfPermission(activity, Manifest.permission.CAMERA);
final boolean permissionsGrated = writePermission == PackageManager.PERMISSION_GRANTED &&
cameraPermission == PackageManager.PERMISSION_GRANTED;
if (!permissionsGrated)
{
final Boolean dontAskAgain = ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) && ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.CAMERA);
if (dontAskAgain)
{
final AlertDialog dialog = PermissionUtils
.explainingDialog(this, options, new PermissionUtils.OnExplainingPermissionCallback()
{
@Override
public void onCancel(WeakReference<ImagePickerModule> moduleInstance,
DialogInterface dialogInterface)
{
final ImagePickerModule module = moduleInstance.get();
if (module == null)
{
return;
}
module.doOnCancel();
}
@Override
public void onReTry(WeakReference<ImagePickerModule> moduleInstance,
DialogInterface dialogInterface)
{
final ImagePickerModule module = moduleInstance.get();
if (module == null)
{
return;
}
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
Uri uri = Uri.fromParts("package", module.getContext().getPackageName(), null);
intent.setData(uri);
final Activity innerActivity = module.getActivity();
if (innerActivity == null)
{
return;
}
innerActivity.startActivityForResult(intent, 1);
}
});
if (dialog != null) {
dialog.show();
}
return false;
}
else
{
String[] PERMISSIONS = {Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA};
if (activity instanceof ReactActivity)
{
((ReactActivity) activity).requestPermissions(PERMISSIONS, requestCode, listener);
}
else if (activity instanceof OnImagePickerPermissionsCallback)
{
((OnImagePickerPermissionsCallback) activity).setPermissionListener(listener);
ActivityCompat.requestPermissions(activity, PERMISSIONS, requestCode);
}
else if (activity instanceof PermissionAwareActivity) {
((PermissionAwareActivity) activity).requestPermissions(PERMISSIONS, requestCode, listener);
}
else
{
final String errorDescription = new StringBuilder(activity.getClass().getSimpleName())
.append(" must implement ")
.append(OnImagePickerPermissionsCallback.class.getSimpleName())
.append(PermissionAwareActivity.class.getSimpleName())
.toString();
throw new UnsupportedOperationException(errorDescription);
}
return false;
}
}
return true;
}
private boolean isCameraAvailable() {
return reactContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA)
|| reactContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY);
}
private @NonNull String getRealPathFromURI(@NonNull final Uri uri) {
return RealPathUtil.getRealPathFromURI(reactContext, uri);
}
/**
* Create a file from uri to allow image picking of image in disk cache
* (Exemple: facebook image, google image etc..)
*
* @doc =>
* https://github.com/nostra13/Android-Universal-Image-Loader#load--display-task-flow
*
* @param uri
* @return File
* @throws Exception
*/
private File createFileFromURI(Uri uri) throws Exception {
File file = new File(reactContext.getExternalCacheDir(), "photo-" + uri.getLastPathSegment());
InputStream input = reactContext.getContentResolver().openInputStream(uri);
OutputStream output = new FileOutputStream(file);
try {
byte[] buffer = new byte[4 * 1024];
int read;
while ((read = input.read(buffer)) != -1) {
output.write(buffer, 0, read);
}
output.flush();
} finally {
output.close();
input.close();
}
return file;
}
private String getBase64StringFromFile(String absoluteFilePath) {
InputStream inputStream = null;
try {
inputStream = new FileInputStream(new File(absoluteFilePath));
} catch (FileNotFoundException e) {
e.printStackTrace();
}
byte[] bytes;
byte[] buffer = new byte[8192];
int bytesRead;
ByteArrayOutputStream output = new ByteArrayOutputStream();
try {
while ((bytesRead = inputStream.read(buffer)) != -1) {
output.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
e.printStackTrace();
}
bytes = output.toByteArray();
return Base64.encodeToString(bytes, Base64.NO_WRAP);
}
private void putExtraFileInfo(@NonNull final String path,
@NonNull final ResponseHelper responseHelper)
{
// size && filename
try {
File f = new File(path);
responseHelper.putDouble("fileSize", f.length());
responseHelper.putString("fileName", f.getName());
} catch (Exception e) {
e.printStackTrace();
}
// type
String extension = MimeTypeMap.getFileExtensionFromUrl(path);
if (extension != null) {
responseHelper.putString("type", MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension));
}
}
private void parseOptions(final ReadableMap options) {
noData = false;
if (options.hasKey("noData")) {
noData = options.getBoolean("noData");
}
imageConfig = imageConfig.updateFromOptions(options);
pickVideo = false;
if (options.hasKey("mediaType") && options.getString("mediaType").equals("video")) {
pickVideo = true;
}
videoQuality = 1;
if (options.hasKey("videoQuality") && options.getString("videoQuality").equals("low")) {
videoQuality = 0;
}
videoDurationLimit = 0;
if (options.hasKey("durationLimit")) {
videoDurationLimit = options.getInt("durationLimit");
}
}
}

179
Makefile
View File

@@ -5,44 +5,32 @@
ios_target := $(filter-out build-ios,$(MAKECMDGOALS))
android_target := $(filter-out build-android,$(MAKECMDGOALS))
POD := $(shell command -v pod 2> /dev/null)
.yarninstall: package.json
@if ! [ $(shell command -v yarn 2> /dev/null) ]; then \
@echo "yarn is not installed https://yarnpkg.com"; \
echo "yarn is not installed https://yarnpkg.com"; \
exit 1; \
fi
@echo Getting Javascript dependencies
@yarn install --pure-lockfile
@echo Getting dependencies using yarn
@touch $@
yarn install --pure-lockfile
.podinstall:
ifdef POD
@echo Getting Cocoapods dependencies;
@cd ios && pod install;
else
@echo "Cocoapods is not installed https://cocoapods.org/"
@exit 1
endif
@touch $@
touch $@
BASE_ASSETS = $(shell find assets/base -type d) $(shell find assets/base -type f -name '*')
OVERRIDE_ASSETS = $(shell find assets/override -type d 2> /dev/null) $(shell find assets/override -type f -name '*' 2> /dev/null)
dist/assets: $(BASE_ASSETS) $(OVERRIDE_ASSETS)
@mkdir -p dist
mkdir -p dist
@if [ -e dist/assets ] ; then \
rm -rf dist/assets; \
fi
@echo "Generating app assets"
@node scripts/make-dist-assets.js
node scripts/make-dist-assets.js
pre-run: | .yarninstall .podinstall dist/assets
pre-run: .yarninstall dist/assets
run: run-ios
@@ -50,141 +38,103 @@ start: | pre-run start-packager
stop: stop-packager
check-device-ios:
run-ios: | start
@if ! [ $(shell command -v xcodebuild) ]; then \
@echo "xcode is not installed"; \
@exit 1; \
echo "xcode is not installed"; \
exit 1; \
fi
@if ! [ $(shell command -v watchman) ]; then \
@echo "watchman is not installed"; \
@exit 1; \
echo "watchman is not installed"; \
exit 1; \
fi
run-ios: | check-device-ios start-build-packager
@echo Running iOS app in development
@react-native run-ios --simulator="${SIMULATOR}"
check-device-android:
npm run run-ios
open -a Simulator
run-android: | start prepare-android-build
@if ! [ $(ANDROID_HOME) ]; then \
@echo "ANDROID_HOME is not set"; \
@exit 1; \
echo "ANDROID_HOME is not set"; \
exit 1; \
fi
@if ! [ $(shell command -v adb 2> /dev/null) ]; then \
@echo "adb is not installed"; \
@exit 1; \
echo "adb is not installed"; \
exit 1; \
fi
ifneq ($(shell adb get-state),device)
@echo "no android device or emulator is running"
@exit 1;
endif
ifneq ($(shell adb get-state),device)
echo "no android device or emulator is running"
exit 1;
endif
@if ! [ $(shell command -v watchman 2> /dev/null) ]; then \
@echo "watchman is not installed"; \
@exit 1; \
echo "watchman is not installed"; \
exit 1; \
fi
run-android: | check-device-android start-build-packager prepare-android-build
@echo Running Android app in development
@react-native run-android --no-packager
test: | pre-run check-style
@yarn test
npm run run-android
test: pre-run
npm test
check-style: .yarninstall
@echo Checking for style guide compliance
@node_modules/.bin/eslint --ext \".js\" --ignore-pattern node_modules --quiet .
npm run check
clean:
@echo Cleaning started
@echo Cleaning app
@yarn cache clean
@rm -rf node_modules
@rm -f .yarninstall
@rm -f .podinstall
@rm -rf dist
@rm -rf ios/build
@rm -rf ios/Pods
@rm -rf android/app/build
@echo Cleanup finished
yarn cache 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
./node_modules/.bin/remotedev-debugger --hostname localhost --port 5678 --injectserver
@# Must remove the .babelrc for 0.42.0 to work correctly
@# Need to copy custom ImagePickerModule.java that implements correct permission checks for android
@rm node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModule.java
@cp ./ImagePickerModule.java node_modules/react-native-image-picker/android/src/main/java/com/imagepicker
@rm -f node_modules/intl/.babelrc
rm -f node_modules/intl/.babelrc
@# Hack to get react-intl and its dependencies to work with react-native
@# Based off of https://github.com/este/este/blob/master/gulp/native-fix.js
@sed -i'' -e 's|"./locale-data/index.js": false|"./locale-data/index.js": "./locale-data/index.js"|g' node_modules/react-intl/package.json
@sed -i'' -e 's|"./lib/locales": false|"./lib/locales": "./lib/locales"|g' node_modules/intl-messageformat/package.json
@sed -i'' -e 's|"./lib/locales": false|"./lib/locales": "./lib/locales"|g' node_modules/intl-relativeformat/package.json
@sed -i'' -e 's|"./locale-data/complete.js": false|"./locale-data/complete.js": "./locale-data/complete.js"|g' node_modules/intl/package.json
@sed -i'' -e 's|auto("auto", Configuration.ORIENTATION_UNDEFINED, ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);|auto("auto", Configuration.ORIENTATION_UNDEFINED, ActivityInfo.SCREEN_ORIENTATION_FULL_USER);|g' node_modules/react-native-navigation/android/app/src/main/java/com/reactnativenavigation/params/Orientation.java
@sed -i'' -e "s|var AndroidTextInput = requireNativeComponent('AndroidTextInput', null);|var AndroidTextInput = requireNativeComponent('CustomTextInput', null);|g" node_modules/react-native/Libraries/Components/TextInput/TextInput.js
@sed -i'' -e 's^getItemLayout || index <= this._highestMeasuredFrameIndex,^!getItemLayout || index !== -1,^g' node_modules/react-native/Libraries/Lists/VirtualizedList.js
@if [ $(shell grep "const Platform" node_modules/react-native/Libraries/Lists/VirtualizedList.js | grep -civ grep) -eq 0 ]; then \
sed $ -i'' -e "s|const ReactNative = require('ReactNative');|const ReactNative = require('ReactNative');`echo $\\\\\\r;`const Platform = require('Platform');|g" node_modules/react-native/Libraries/Lists/VirtualizedList.js; \
fi
@sed -i'' -e 's|transform: \[{scaleY: -1}\],|...Platform.select({android: {transform: \[{perspective: 1}, {scaleY: -1}\]}, ios: {transform: \[{scaleY: -1}\]}}),|g' node_modules/react-native/Libraries/Lists/VirtualizedList.js
@cd ./node_modules/react-native-svg/ios && rm -rf PerformanceBezier && git clone https://github.com/adamwulf/PerformanceBezier.git
@cd ./node_modules/mattermost-redux && yarn run build
sed -i'' -e 's|"./locale-data/index.js": false|"./locale-data/index.js": "./locale-data/index.js"|g' node_modules/react-intl/package.json
sed -i'' -e 's|"./lib/locales": false|"./lib/locales": "./lib/locales"|g' node_modules/intl-messageformat/package.json
sed -i'' -e 's|"./lib/locales": false|"./lib/locales": "./lib/locales"|g' node_modules/intl-relativeformat/package.json
sed -i'' -e 's|"./locale-data/complete.js": false|"./locale-data/complete.js": "./locale-data/complete.js"|g' node_modules/intl/package.json
start-packager:
@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; \
else \
echo React Native packager server already running; \
ps -e | grep -i "cli.js start" | grep -v grep | awk '{print $$1}' > server.PID; \
fi
start-build-packager:
@if [ $(shell ps -e | grep -i "cli.js start" | grep -civ grep) -eq 0 ]; then \
@if [ $(shell ps -a | grep "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 -e | grep -i "cli.js start" | grep -v grep | awk '{print $$1}' > server.PID; \
ps -a | grep -i "cli.js start" | grep -v grep | awk '{print $$1}' > server.PID; \
fi
stop-packager:
@echo Stopping React Native packager server
@if [ -e "server.PID" ] ; then \
kill -9 `cat server.PID` && rm server.PID; \
echo React Native packager server stopped; \
else \
echo No React Native packager server running; \
fi
check-ios-target:
ifeq ($(ios_target), )
@echo No target set to build iOS app
@echo "Try running make build-ios TARGET where TARGET is one of dev, beta or release"
@exit 1
endif
ifneq ($(ios_target), $(filter $(ios_target),dev beta release))
@echo Invalid target set to build iOS app
@echo "Try running make build-ios TARGET where TARGET is one of dev, beta or release"
ifneq ($(ios_target), $(filter $(ios_target), dev beta release))
@echo "Try running make build-ios TARGET\nWhere TARGET is one of dev, beta or release"
@exit 1
endif
do-build-ios:
@echo "Building ios $(ios_target) app"
@cd fastlane && BABEL_ENV=production NODE_ENV=production bundle exec fastlane ios $(ios_target)
@cd fastlane && NODE_ENV=production bundle exec fastlane ios $(ios_target)
build-ios: | check-ios-target pre-run check-style start-build-packager do-build-ios stop-packager
build-ios: | check-ios-target pre-run check-style start-packager do-build-ios stop-packager
check-android-target:
ifeq ($(android_target), )
@echo No target set to build Android app
@echo "Try running make build-android TARGET where TARGET is one of dev, beta or release"
@exit 1
endif
ifneq ($(android_target), $(filter $(android_target),dev alpha release))
@echo Invalid target set to build Android app
@echo "Try running make build-android TARGET where TARGET is one of dev, beta or release"
ifneq ($(android_target), $(filter $(android_target), dev alpha release))
@echo "Try running make build-android TARGET\nWhere TARGET is one of dev, beta or release"
@exit 1
endif
@@ -192,30 +142,13 @@ prepare-android-build:
@rm -rf ./node_modules/react-native/local-cli/templates/HelloWorld
@rm -rf ./node_modules/react-native-linear-gradient/Examples/
@rm -rf ./node_modules/react-native-orientation/demo/
@cd android && ./gradlew clean
do-build-android:
@echo "Building android $(android_target) app"
@cd fastlane && BABEL_ENV=production NODE_ENV=production bundle exec fastlane android $(android_target)
@cd fastlane && NODE_ENV=production bundle exec fastlane android $(android_target)
build-android: | check-android-target pre-run check-style start-build-packager prepare-android-build do-build-android stop-packager
do-unsigned-ios:
@echo "Building unsigned iOS app"
@cd fastlane && NODE_ENV=production bundle exec fastlane ios unsigned
@mkdir -p build-ios
@cd ios/ && xcodebuild -workspace Mattermost.xcworkspace/ -scheme Mattermost -sdk iphoneos -configuration Relase -parallelizeTargets -resultBundlePath ../build-ios/result -derivedDataPath ../build-ios/ CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
@cd build-ios/ && mkdir -p Payload && cp -R Build/Products/Release-iphoneos/Mattermost.app Payload/ && zip -r Mattermost-unsigned.ipa Payload/
@mv build-ios/Mattermost-unsigned.ipa .
@rm -rf build-ios/
do-unsigned-android:
@echo "Building unsigned Android app"
@cd fastlane && NODE_ENV=production bundle exec fastlane android unsigned
@mv android/app/build/outputs/apk/app-unsigned-unsigned.apk ./Mattermost-unsigned.apk
unsigned-android: pre-run check-style start-build-packager do-unsigned-android stop-packager
unsigned-ios: pre-run check-style start-build-packager do-unsigned-ios stop-packager
build-android: | check-android-target pre-run check-style start-packager prepare-android-build do-build-android stop-packager
alpha:
@:

1192
NOTICE.txt

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,32 @@
# Mattermost Mobile
# Mattermost Mobile (unreleased)
**Supported Server Versions:** 4.0+
**Supported Server Versions:** 3.8+
**Supported iOS versions:** 9.3+
**Supported Android versions:** 5.0+
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
To help with testing app updates before they're released, you can:
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.
1. Sign up to be a beta tester
- [Android](https://play.google.com/apps/testing/com.mattermost.rnbeta)
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)
- [iOS](https://mattermost-fastlane.herokuapp.com/)
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:
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:
- 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
@@ -37,7 +36,6 @@ To help with testing app updates before they're released, 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:
@@ -84,8 +82,7 @@ Follow the [React Native Getting Started Guide](https://facebook.github.io/react
$ cd watchman
$ git checkout master
$ ./autogen.sh
$ ./configure
$ make
$ ./configure make
$ sudo make install
```
Configure your kernel to accept a lot of file watches, using a command like:
@@ -101,7 +98,7 @@ Follow the [React Native Getting Started Guide](https://facebook.github.io/react
$ npm install -g react-native-cli
```
- You can create a file named `assets/override/config.json` and add the url to the Mattermost server that you will use to develop:
- Add or edit file `src/config/config.secret.json` and add the url to the Mattermost server that you will use to develop:
`{
"DefaultServerUrl": "https://pre-release.mattermost.com"
}`
@@ -118,42 +115,6 @@ 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."
This sometimes appears when there is an issue with the SSL certitificate configuration.
To check that your SSL certificate is set up correctly, test the SSL certificate by visiting a site such as https://www.ssllabs.com/ssltest/index.html. If theres an error about the missing chain or certificate path, there is likely an intermediate certificate missing that needs to be included.
Please note that the apps cannot connect to servers with self-signed certificates, consider using [Let's Encrypt](https://docs.mattermost.com/install/config-ssl-http2-nginx.html) instead.
### 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).
# Issues building app for own device using make build-*
That command is an internal pipeline command for mattermost mobile to publish the mobile apps to ````Apple App Store```` and ````Google Play Store````. All ````make build-*```` commands should be avoided for this reason.
To build the modified react native client use the instructions for [Running on Device](http://facebook.github.io/react-native/docs/running-on-device.html) from the [React Native Guide](https://facebook.github.io/react-native/docs/getting-started.html).

View File

@@ -1,3 +1,5 @@
import re
# To learn about Buck see [Docs](https://buckbuild.com/).
# To run your application with Buck:
# - install Buck
@@ -9,9 +11,8 @@
#
lib_deps = []
for jarfile in glob(['libs/*.jar']):
name = 'jars__' + jarfile[jarfile.rindex('/') + 1: jarfile.rindex('.jar')]
name = 'jars__' + re.sub(r'^.*/([^/]+)\.jar$', r'\1', jarfile)
lib_deps.append(':' + name)
prebuilt_jar(
name = name,
@@ -19,7 +20,7 @@ for jarfile in glob(['libs/*.jar']):
)
for aarfile in glob(['libs/*.aar']):
name = 'aars__' + aarfile[aarfile.rindex('/') + 1: aarfile.rindex('.aar')]
name = 'aars__' + re.sub(r'^.*/([^/]+)\.aar$', r'\1', aarfile)
lib_deps.append(':' + name)
android_prebuilt_aar(
name = name,
@@ -27,39 +28,39 @@ for aarfile in glob(['libs/*.aar']):
)
android_library(
name = "all-libs",
exported_deps = lib_deps,
name = 'all-libs',
exported_deps = lib_deps
)
android_library(
name = "app-code",
srcs = glob([
"src/main/java/**/*.java",
]),
deps = [
":all-libs",
":build_config",
":res",
],
name = 'app-code',
srcs = glob([
'src/main/java/**/*.java',
]),
deps = [
':all-libs',
':build_config',
':res',
],
)
android_build_config(
name = "build_config",
package = "com.mattermost-mobile",
name = 'build_config',
package = 'com.mattermost.rnbeta',
)
android_resource(
name = "res",
res = "src/main/res",
package = "com.mattermost.rnbeta",
name = 'res',
res = 'src/main/res',
package = 'com.mattermost.rnbeta',
)
android_binary(
name = "app",
keystore = "//android/keystores:debug",
manifest = "src/main/AndroidManifest.xml",
package_type = "debug",
deps = [
":app-code",
],
name = 'app',
package_type = 'debug',
manifest = 'src/main/AndroidManifest.xml',
keystore = '//android/keystores:debug',
deps = [
':app-code',
],
)

View File

@@ -33,13 +33,6 @@ import com.android.build.OutputFile
* // bundleInPaidRelease: true,
* // bundleInBeta: true,
*
* // whether to disable dev mode in custom build variants (by default only disabled in release)
* // for example: to disable dev mode in the staging build type (if configured)
* devDisabledInStaging: true,
* // The configuration property can be in the following formats
* // 'devDisabledIn${productFlavor}${buildType}'
* // 'devDisabledIn${buildType}'
*
* // the root of your project, i.e. where "package.json" lives
* root: "../../",
*
@@ -65,24 +58,16 @@ import com.android.build.OutputFile
* inputExcludes: ["android/**", "ios/**"],
*
* // override which node gets called and with what additional arguments
* nodeExecutableAndArgs: ["node"],
* nodeExecutableAndArgs: ["node"]
*
* // supply additional arguments to the packager
* extraPackagerArgs: []
* ]
*/
project.ext.react = [
entryFile: "index.js"
]
apply from: "../../node_modules/react-native/react.gradle"
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
if (System.getenv("SENTRY_ENABLED") == "true") {
apply from: "../../node_modules/react-native-sentry/sentry.gradle"
}
/**
* Set this to true to create two separate APKs instead of one:
* - An APK that only works on ARM devices
@@ -104,10 +89,10 @@ android {
defaultConfig {
applicationId "com.mattermost.rnbeta"
minSdkVersion 21
minSdkVersion 16
targetSdkVersion 23
versionCode 74
versionName "1.5.1"
versionCode 41
versionName "1.0.1"
multiDexEnabled true
ndk {
abiFilters "armeabi-v7a", "x86"
@@ -142,10 +127,6 @@ android {
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
unsigned.initWith(buildTypes.release)
unsigned {
signingConfig null
}
}
// applicationVariants are e.g. debug, release
applicationVariants.all { variant ->
@@ -163,11 +144,8 @@ android {
}
dependencies {
compile project(':react-native-doc-viewer')
compile project(':react-native-video')
compile fileTree(dir: "libs", include: ["*.jar"])
compile "com.android.support:appcompat-v7:25.0.1"
compile 'com.android.support:percent:25.3.1'
compile "com.facebook.react:react-native:+" // From node_modules
compile project(':react-native-navigation')
compile project(':react-native-image-picker')
@@ -182,19 +160,6 @@ dependencies {
compile project(':react-native-linear-gradient')
compile project(':react-native-vector-icons')
compile project(':react-native-svg')
compile project(':react-native-local-auth')
compile project(':jail-monkey')
compile project(':react-native-youtube')
compile project(':react-native-sentry')
compile project(':react-native-exception-handler')
compile project(':react-native-fetch-blob')
// For animated GIF support
compile 'com.facebook.fresco:animated-base-support:1.3.0'
// For WebP support, including animated WebP
compile 'com.facebook.fresco:animated-gif:1.3.0'
compile 'com.facebook.fresco:animated-webp:1.3.0'
compile 'com.facebook.fresco:webpsupport:1.3.0'
}
// Run this once to be able to run the application with BUCK

View File

@@ -50,10 +50,6 @@
-dontwarn com.facebook.react.**
# TextLayoutBuilder uses a non-public Android constructor within StaticLayout.
# See libs/proxy/src/main/java/com/facebook/fbui/textlayoutbuilder/proxy for details.
-dontwarn android.text.StaticLayout
# okhttp
-keepattributes Signature

View File

@@ -14,7 +14,6 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
<uses-permission android:name="com.google.android.c2dm.permission.SEND" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -30,10 +29,7 @@
android:icon="@mipmap/ic_launcher"
android:theme="@style/AppTheme"
>
<meta-data android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
<meta-data android:name="com.wix.reactnativenotifications.gcmSenderId" android:value="184930218130\0"/>
<meta-data android:name="com.wix.reactnativenotifications.gcmSenderId" android:value="184930218130\0"/>
<activity
android:name=".MainActivity"
android:label="@string/app_name"
@@ -43,19 +39,11 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.DOWNLOAD_COMPLETE"/>
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
<service android:name=".NotificationDismissService"
android:enabled="true"
android:exported="false" />
<service android:name=".NotificationReplyService"
android:enabled="true"
android:exported="false" />
<activity
android:name="com.reactnativenavigation.controllers.NavigationActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"/>
</application>
</manifest>

View File

@@ -1,30 +0,0 @@
package com.mattermost.components;
import android.content.Context;
import android.text.InputType;
import com.facebook.react.views.textinput.ReactEditText;
public class CustomTextInput extends ReactEditText {
private boolean autoScroll = false;
public CustomTextInput(Context context) {
super(context);
}
private boolean isMultiline() {
return (getInputType() & InputType.TYPE_TEXT_FLAG_MULTI_LINE) != 0;
}
@Override
public boolean isLayoutRequested() {
if (isMultiline() && !autoScroll) {
return true;
}
return false;
}
public void setAutoScroll(boolean autoScroll) {
this.autoScroll = autoScroll;
}
}

View File

@@ -1,35 +0,0 @@
package com.mattermost.components;
import android.text.InputType;
import android.util.TypedValue;
import com.facebook.react.views.textinput.ReactTextInputManager;
import com.facebook.react.uimanager.PixelUtil;
import com.facebook.react.uimanager.ThemedReactContext;
import com.facebook.react.uimanager.ViewDefaults;
import com.facebook.react.uimanager.annotations.ReactProp;
public class CustomTextInputManager extends ReactTextInputManager {
@Override
public String getName() {
return "CustomTextInput";
}
@Override
public CustomTextInput createViewInstance(ThemedReactContext context) {
CustomTextInput editText = new CustomTextInput(context);
int inputType = editText.getInputType();
editText.setInputType(inputType & (~InputType.TYPE_TEXT_FLAG_MULTI_LINE));
editText.setReturnKeyType("done");
editText.setTextSize(
TypedValue.COMPLEX_UNIT_PX,
(int) Math.ceil(PixelUtil.toPixelFromSP(ViewDefaults.FONT_SIZE_SP)));
return editText;
}
@ReactProp(name = "autoScroll", defaultBoolean = false)
public void setAutoScroll(CustomTextInput view, boolean autoScroll) {
view.setAutoScroll(autoScroll);
}
}

View File

@@ -1,77 +1,33 @@
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;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.media.AudioManager;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Build;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.RemoteInput;
import android.provider.Settings.System;
import java.util.LinkedHashMap;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
import java.lang.reflect.Field;
import com.wix.reactnativenotifications.core.notification.PushNotification;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
import com.wix.reactnativenotifications.core.JsIOHelper;
import com.wix.reactnativenotifications.helpers.ApplicationBadgeHelper;
import android.util.Log;
import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME;
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";
public static final String KEY_TEXT_REPLY = "CAN_REPLY";
public static final String NOTIFICATION_REPLIED_EVENT_NAME = "notificationReplied";
private static LinkedHashMap<String,Integer> channelIdToNotificationCount = new LinkedHashMap<String,Integer>();
private static LinkedHashMap<String,List<Bundle>> channelIdToNotification = new LinkedHashMap<String,List<Bundle>>();
private static AppLifecycleFacade lifecycleFacade;
private static Context context;
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper);
this.context = context;
}
public static void clearNotification(int notificationId, String channelId) {
if (notificationId != -1) {
channelIdToNotificationCount.remove(channelId);
channelIdToNotification.remove(channelId);
if (context != null) {
final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(notificationId);
}
}
}
public static void clearNotification(Context mContext, int notificationId, String channelId) {
if (notificationId != -1) {
channelIdToNotificationCount.remove(channelId);
channelIdToNotification.remove(channelId);
if (mContext != null) {
final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(notificationId);
}
}
}
@Override
@@ -88,18 +44,6 @@ public class CustomPushNotification extends PushNotification {
count = (Integer)objCount + 1;
}
channelIdToNotificationCount.put(channelId, count);
Object bundleArray = channelIdToNotification.get(channelId);
List list = null;
if (bundleArray == null) {
list = Collections.synchronizedList(new ArrayList(0));
} else {
list = Collections.synchronizedList((List)bundleArray);
}
synchronized (list) {
list.add(0, data);
channelIdToNotification.put(channelId, list);
}
}
if ("clear".equals(type)) {
@@ -111,25 +55,9 @@ 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) {
boolean force = false;
Bundle bundle = notification.extras;
if (bundle != null) {
force = bundle.getBoolean("localTest");
}
if (!mAppLifecycleFacade.isAppVisible() || force) {
if (!mAppLifecycleFacade.isAppVisible()) {
super.postNotification(id, notification);
}
}
@@ -138,10 +66,9 @@ public class CustomPushNotification extends PushNotification {
protected Notification.Builder getNotificationBuilder(PendingIntent intent) {
final Resources res = mContext.getResources();
String packageName = mContext.getPackageName();
NotificationPreferences notificationPreferences = NotificationPreferences.getInstance(mContext);
// First, get a builder initialized with defaults from the core class.
final Notification.Builder notification = new Notification.Builder(mContext);
final Notification.Builder notification = super.getNotificationBuilder(intent);
Bundle bundle = mNotificationProps.asBundle();
String title = bundle.getString("title");
if (title == null) {
@@ -149,20 +76,14 @@ public class CustomPushNotification extends PushNotification {
title = mContext.getPackageManager().getApplicationLabel(appInfo).toString();
}
int notificationId = bundle.getString("channel_id").hashCode();
String channelId = bundle.getString("channel_id");
String postId = bundle.getString("post_id");
int notificationId = channelId != null ? channelId.hashCode() : MESSAGE_NOTIFICATION_ID;
String message = bundle.getString("message");
String subText = bundle.getString("subText");
String numberString = bundle.getString("badge");
String smallIcon = bundle.getString("smallIcon");
String largeIcon = bundle.getString("largeIcon");
Bundle b = bundle.getBundle("userInfo");
if (b != null) {
notification.addExtras(b);
}
int smallIconResId;
int largeIconResId;
@@ -190,71 +111,26 @@ public class CustomPushNotification extends PushNotification {
ApplicationBadgeHelper.instance.setApplicationIconBadgeNumber(mContext.getApplicationContext(), Integer.parseInt(numberString));
}
int numMessages = getMessageCountInChannel(channelId);
int numMessages = 0;
Object objCount = channelIdToNotificationCount.get(channelId);
if (objCount != null) {
numMessages = (Integer)objCount;
}
notification
.setContentIntent(intent)
.setContentTitle(title)
.setContentText(message)
.setGroupSummary(true)
.setSmallIcon(smallIconResId)
.setVisibility(Notification.VISIBILITY_PRIVATE)
.setPriority(Notification.PRIORITY_HIGH)
.setAutoCancel(true);
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();
List<Bundle> list = new ArrayList<Bundle>(channelIdToNotification.get(channelId));
for (Bundle data : list){
String msg = data.getString("message");
if (msg != message) {
style.addLine(data.getString("message"));
}
}
style.setBigContentTitle(message)
.setSummaryText(String.format("+%d more", (numMessages - 1)));
notification.setStyle(style)
.setContentTitle(summaryTitle);
}
// Let's add a delete intent when the notification is dismissed
Intent delIntent = new Intent(mContext, NotificationDismissService.class);
delIntent.putExtra(NOTIFICATION_ID, notificationId);
PendingIntent deleteIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, delIntent, mNotificationProps);
notification.setDeleteIntent(deleteIntent);
.setPriority(Notification.PRIORITY_HIGH);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
notification.setGroup(GROUP_KEY_MESSAGES);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && postId != null) {
Intent replyIntent = new Intent(mContext, NotificationReplyService.class);
replyIntent.setAction(KEY_TEXT_REPLY);
replyIntent.putExtra(NOTIFICATION_ID, notificationId);
replyIntent.putExtra("pushNotification", bundle);
PendingIntent replyPendingIntent = PendingIntent.getService(mContext, 103, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT);
RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel("Reply")
.build();
Notification.Action replyAction = new Notification.Action.Builder(
R.drawable.ic_notif_action_reply, "Reply", replyPendingIntent)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.build();
notification
.setShowWhen(true)
.addAction(replyAction);
if (numMessages > 1) {
notification.setNumber(numMessages);
}
Bitmap largeIconBitmap = BitmapFactory.decodeResource(res, largeIconResId);
@@ -266,27 +142,6 @@ public class CustomPushNotification extends PushNotification {
notification.setSubText(subText);
}
String soundUri = notificationPreferences.getNotificationSound();
if (soundUri != null) {
if (soundUri != "none") {
notification.setSound(Uri.parse(soundUri), AudioManager.STREAM_NOTIFICATION);
}
} else {
Uri defaultUri = System.DEFAULT_NOTIFICATION_URI;
notification.setSound(defaultUri, AudioManager.STREAM_NOTIFICATION);
}
boolean vibrate = notificationPreferences.getShouldVibrate();
if (vibrate) {
// use the system default for vibration
notification.setDefaults(Notification.DEFAULT_VIBRATE);
}
boolean blink = notificationPreferences.getShouldBlink();
if (blink) {
notification.setLights(Color.CYAN, 500, 500);
}
return notification;
}
@@ -294,15 +149,6 @@ public class CustomPushNotification extends PushNotification {
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.getRunningReactContext());
}
public static Integer getMessageCountInChannel(String channelId) {
Object objCount = channelIdToNotificationCount.get(channelId);
if (objCount != null) {
return (Integer)objCount;
}
return 1;
}
private void cancelNotification(Bundle data, int notificationId) {
final String channelId = data.getString("channel_id");
@@ -312,7 +158,6 @@ 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,10 +1,67 @@
package com.mattermost.rnbeta;
import com.github.yamill.orientation.OrientationPackage;
import com.psykar.cookiemanager.CookieManagerPackage;
import com.BV.LinearGradient.LinearGradientPackage;
import com.reactnativenavigation.controllers.SplashActivity;
import java.lang.ref.WeakReference;
import android.content.Context;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.graphics.Color;
import android.widget.TextView;
import android.view.ViewGroup.LayoutParams;
import android.view.Gravity;
import android.util.TypedValue;
public class MainActivity extends SplashActivity {
@Override
public int getSplashLayout() {
return R.layout.launch_screen;
}
private static ImageView imageView;
private static WeakReference<MainActivity> wr_activity;
protected static MainActivity getActivity() {
return wr_activity.get();
}
/**
* Returns the name of the main component registered from JavaScript.
* This is used to schedule rendering of the component.
*/
// @Override
// protected String getMainComponentName() {
// return "Mattermost";
// }
@Override
public LinearLayout createSplashLayout() {
wr_activity = new WeakReference<>(this);
final int drawableId = getImageId();
Context context = getActivity();
imageView = new ImageView(context);
imageView.setImageResource(drawableId);
LayoutParams layoutParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
imageView.setLayoutParams(layoutParams);
imageView.setScaleType(ImageView.ScaleType.CENTER);
LinearLayout view = new LinearLayout(this);
view.setBackgroundColor(Color.parseColor("#FFFFFF"));
view.setGravity(Gravity.CENTER);
view.addView(imageView);
return view;
}
private static int getImageId() {
int drawableId = getActivity().getResources().getIdentifier("splash", "drawable", getActivity().getClass().getPackage().getName());
if (drawableId == 0) {
drawableId = getActivity().getResources().getIdentifier("splash", "drawable", getActivity().getPackageName());
}
return drawableId;
}
}

View File

@@ -1,20 +1,12 @@
package com.mattermost.rnbeta;
import android.app.Application;
import android.util.Log;
import android.support.annotation.NonNull;
import android.content.Context;
import android.os.Bundle;
import com.facebook.react.ReactApplication;
import com.reactlibrary.RNReactNativeDocViewerPackage;
import com.brentvatne.react.ReactVideoPackage;
import com.horcrux.svg.SvgPackage;
import com.inprogress.reactnativeyoutube.ReactNativeYouTube;
import io.sentry.RNSentryPackage;
import com.masteratul.exceptionhandler.ReactNativeExceptionHandlerPackage;
import com.RNFetchBlob.RNFetchBlobPackage;
import com.gantix.JailMonkey.JailMonkeyPackage;
import io.tradle.react.LocalAuthPackage;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
@@ -26,6 +18,7 @@ import com.gnet.bottomsheet.RNBottomSheetPackage;
import com.learnium.RNDeviceInfo.RNDeviceInfo;
import com.psykar.cookiemanager.CookieManagerPackage;
import com.oblador.vectoricons.VectorIconsPackage;
import com.horcrux.svg.SvgPackage;
import com.BV.LinearGradient.LinearGradientPackage;
import com.github.yamill.orientation.OrientationPackage;
import com.reactnativenavigation.NavigationApplication;
@@ -40,7 +33,8 @@ import java.util.Arrays;
import java.util.List;
public class MainApplication extends NavigationApplication implements INotificationsApplication {
public NotificationsLifecycleFacade notificationsLifecycleFacade;
NotificationsLifecycleFacade notificationsLifecycleFacade;
@Override
public boolean isDebug() {
@@ -61,33 +55,20 @@ public class MainApplication extends NavigationApplication implements INotificat
new SvgPackage(),
new LinearGradientPackage(),
new OrientationPackage(),
new RNNotificationsPackage(this),
new LocalAuthPackage(),
new JailMonkeyPackage(),
new RNFetchBlobPackage(),
new MattermostPackage(this),
new RNSentryPackage(this),
new ReactNativeExceptionHandlerPackage(),
new ReactNativeYouTube(),
new ReactVideoPackage(),
new RNReactNativeDocViewerPackage()
new RNNotificationsPackage(MainApplication.this)
);
}
@Override
public String getJSMainModuleName() {
return "index";
}
@Override
public void onCreate() {
super.onCreate();
instance = this;
// Create an object of the custom facade impl
notificationsLifecycleFacade = NotificationsLifecycleFacade.getInstance();
notificationsLifecycleFacade = new NotificationsLifecycleFacade();
// Attach it to react-native-navigation
setActivityCallbacks(notificationsLifecycleFacade);
SoLoader.init(this, /* native exopackage */ false);
}

View File

@@ -1,65 +0,0 @@
package com.mattermost.rnbeta;
import android.app.Application;
import android.content.Context;
import android.os.Bundle;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
public class MattermostManagedModule extends ReactContextBaseJavaModule {
private static MattermostManagedModule instance;
private boolean shouldBlurAppScreen = false;
private MattermostManagedModule(ReactApplicationContext reactContext) {
super(reactContext);
}
public static MattermostManagedModule getInstance(ReactApplicationContext reactContext) {
if (instance == null) {
instance = new MattermostManagedModule(reactContext);
}
return instance;
}
public static MattermostManagedModule getInstance() {
return instance;
}
@Override
public String getName() {
return "MattermostManaged";
}
@ReactMethod
public void blurAppScreen(boolean enabled) {
shouldBlurAppScreen = enabled;
}
public boolean isBlurAppScreenEnabled() {
return shouldBlurAppScreen;
}
@ReactMethod
public void getConfig(final Promise promise) {
try {
Bundle config = NotificationsLifecycleFacade.getInstance().getManagedConfig();
if (config != null) {
Object result = Arguments.fromBundle(config);
promise.resolve(result);
} else {
throw new Exception("The MDM vendor has not sent any Managed configuration");
}
} catch (Exception e) {
promise.reject("no managed configuration", e);
}
}
}

View File

@@ -1,36 +0,0 @@
package com.mattermost.rnbeta;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import com.facebook.react.bridge.JavaScriptModule;
import com.mattermost.components.CustomTextInputManager;
public class MattermostPackage implements ReactPackage {
private final MainApplication mApplication;
public MattermostPackage(MainApplication application) {
mApplication = application;
}
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
return Arrays.<NativeModule>asList(
MattermostManagedModule.getInstance(reactContext),
NotificationPreferencesModule.getInstance(mApplication, reactContext)
);
}
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Arrays.<ViewManager>asList(
new CustomTextInputManager()
);
}
}

View File

@@ -1,26 +0,0 @@
package com.mattermost.rnbeta;
import android.content.Context;
import android.content.Intent;
import android.app.IntentService;
import android.os.Bundle;
import android.util.Log;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
public class NotificationDismissService extends IntentService {
private Context mContext;
public NotificationDismissService() {
super("notificationDismissService");
}
@Override
protected void onHandleIntent(Intent intent) {
mContext = getApplicationContext();
Bundle bundle = NotificationIntentAdapter.extractPendingNotificationDataFromIntent(intent);
int notificationId = intent.getIntExtra(CustomPushNotification.NOTIFICATION_ID, -1);
String channelId = bundle.getString("channel_id");
CustomPushNotification.clearNotification(mContext, notificationId, channelId);
Log.i("ReactNative", "Dismiss notification");
}
}

View File

@@ -1,57 +0,0 @@
package com.mattermost.rnbeta;
import android.content.Context;
import android.content.SharedPreferences;
public class NotificationPreferences {
private static NotificationPreferences instance;
public final String SHARED_NAME = "NotificationPreferences";
public final String SOUND_PREF = "NotificationSound";
public final String VIBRATE_PREF = "NotificationVibrate";
public final String BLINK_PREF = "NotificationLights";
private SharedPreferences mSharedPreferences;
private NotificationPreferences(Context context) {
mSharedPreferences = context.getSharedPreferences(SHARED_NAME, Context.MODE_PRIVATE);
}
public static NotificationPreferences getInstance(Context context) {
if (instance == null) {
instance = new NotificationPreferences(context);
}
return instance;
}
public String getNotificationSound() {
return mSharedPreferences.getString(SOUND_PREF, null);
}
public boolean getShouldVibrate() {
return mSharedPreferences.getBoolean(VIBRATE_PREF, true);
}
public boolean getShouldBlink() {
return mSharedPreferences.getBoolean(BLINK_PREF, false);
}
public void setNotificationSound(String soundUri) {
SharedPreferences.Editor editor = mSharedPreferences.edit();
editor.putString(SOUND_PREF, soundUri);
editor.commit();
}
public void setShouldVibrate(boolean vibrate) {
SharedPreferences.Editor editor = mSharedPreferences.edit();
editor.putBoolean(VIBRATE_PREF, vibrate);
editor.commit();
}
public void setShouldBlink(boolean blink) {
SharedPreferences.Editor editor = mSharedPreferences.edit();
editor.putBoolean(BLINK_PREF, blink);
editor.commit();
}
}

View File

@@ -1,106 +0,0 @@
package com.mattermost.rnbeta;
import android.app.Application;
import android.content.Context;
import android.database.Cursor;
import android.media.Ringtone;
import android.media.RingtoneManager;
import android.os.Bundle;
import android.net.Uri;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
public class NotificationPreferencesModule extends ReactContextBaseJavaModule {
private static NotificationPreferencesModule instance;
private final MainApplication mApplication;
private NotificationPreferences mNotificationPreference;
private NotificationPreferencesModule(MainApplication application, ReactApplicationContext reactContext) {
super(reactContext);
mApplication = application;
Context context = mApplication.getApplicationContext();
mNotificationPreference = NotificationPreferences.getInstance(context);
}
public static NotificationPreferencesModule getInstance(MainApplication application, ReactApplicationContext reactContext) {
if (instance == null) {
instance = new NotificationPreferencesModule(application, reactContext);
}
return instance;
}
public static NotificationPreferencesModule getInstance() {
return instance;
}
@Override
public String getName() {
return "NotificationPreferences";
}
@ReactMethod
public void getPreferences(final Promise promise) {
try {
Context context = mApplication.getApplicationContext();
RingtoneManager manager = new RingtoneManager(context);
manager.setType(RingtoneManager.TYPE_NOTIFICATION);
Cursor cursor = manager.getCursor();
WritableMap result = Arguments.createMap();
WritableArray sounds = Arguments.createArray();
while (cursor.moveToNext()) {
String notificationTitle = cursor.getString(RingtoneManager.TITLE_COLUMN_INDEX);
String notificationId = cursor.getString(RingtoneManager.ID_COLUMN_INDEX);
String notificationUri = cursor.getString(RingtoneManager.URI_COLUMN_INDEX);
WritableMap map = Arguments.createMap();
map.putString("name", notificationTitle);
map.putString("uri", (notificationUri + "/" + notificationId));
sounds.pushMap(map);
}
Uri defaultUri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_NOTIFICATION);
result.putString("defaultUri", Uri.decode(defaultUri.toString()));
result.putString("selectedUri", mNotificationPreference.getNotificationSound());
result.putBoolean("shouldVibrate", mNotificationPreference.getShouldVibrate());
result.putBoolean("shouldBlink", mNotificationPreference.getShouldBlink());
result.putArray("sounds", sounds);
promise.resolve(result);
} catch (Exception e) {
promise.reject("no notification sounds found", e);
}
}
@ReactMethod
public void previewSound(String url) {
Context context = mApplication.getApplicationContext();
Uri uri = Uri.parse(url);
Ringtone r = RingtoneManager.getRingtone(context, uri);
r.play();
}
@ReactMethod
public void setNotificationSound(String soundUri) {
mNotificationPreference.setNotificationSound(soundUri);
}
@ReactMethod
public void setShouldVibrate(boolean vibrate) {
mNotificationPreference.setShouldVibrate(vibrate);
}
@ReactMethod
public void setShouldBlink(boolean blink) {
mNotificationPreference.setShouldBlink(blink);
}
}

View File

@@ -1,53 +0,0 @@
package com.mattermost.rnbeta;
import android.content.Context;
import android.content.Intent;
import android.app.NotificationManager;
import android.app.RemoteInput;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.util.Log;
import com.facebook.react.HeadlessJsTaskService;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.jstasks.HeadlessJsTaskConfig;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
public class NotificationReplyService extends HeadlessJsTaskService {
private Context mContext;
@Override
protected @Nullable HeadlessJsTaskConfig getTaskConfig(Intent intent) {
mContext = getApplicationContext();
if (CustomPushNotification.KEY_TEXT_REPLY.equals(intent.getAction())) {
CharSequence message = getReplyMessage(intent);
Bundle bundle = NotificationIntentAdapter.extractPendingNotificationDataFromIntent(intent);
String channelId = bundle.getString("channel_id");
bundle.putCharSequence("text", message);
bundle.putInt("msg_count", CustomPushNotification.getMessageCountInChannel(channelId));
int notificationId = intent.getIntExtra(CustomPushNotification.NOTIFICATION_ID, -1);
CustomPushNotification.clearNotification(mContext, notificationId, channelId);
Log.i("ReactNative", "Replying service");
return new HeadlessJsTaskConfig(
"notificationReplied",
Arguments.fromBundle(bundle),
5000);
}
return null;
}
private CharSequence getReplyMessage(Intent intent) {
Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
if (remoteInput != null) {
return remoteInput.getCharSequence(CustomPushNotification.KEY_TEXT_REPLY);
}
return null;
}
}

View File

@@ -1,22 +1,9 @@
package com.mattermost.rnbeta;
import android.app.Activity;
import android.content.pm.ActivityInfo;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.RestrictionsManager;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.util.Log;
import android.util.ArraySet;
import android.view.WindowManager.LayoutParams;
import android.content.res.Configuration;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.reactnativenavigation.NavigationApplication;
import com.reactnativenavigation.controllers.ActivityCallbacks;
import com.reactnativenavigation.react.ReactGateway;
@@ -25,72 +12,16 @@ import com.wix.reactnativenotifications.core.AppLifecycleFacade;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
public class NotificationsLifecycleFacade extends ActivityCallbacks implements AppLifecycleFacade {
private static final String TAG = NotificationsLifecycleFacade.class.getSimpleName();
private static NotificationsLifecycleFacade instance;
private Bundle managedConfig = null;
private static final String TAG = NotificationsLifecycleFacade.class.getSimpleName();
private Activity mVisibleActivity;
private Set<AppVisibilityListener> mListeners = new CopyOnWriteArraySet<>();
private final IntentFilter restrictionsFilter =
new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED);
private final BroadcastReceiver restrictionsReceiver = new BroadcastReceiver() {
@Override public void onReceive(Context context, Intent intent) {
if (mVisibleActivity != null) {
// Get the current configuration bundle
RestrictionsManager myRestrictionsMgr =
(RestrictionsManager) mVisibleActivity
.getSystemService(Context.RESTRICTIONS_SERVICE);
managedConfig = myRestrictionsMgr.getApplicationRestrictions();
// Check current configuration settings, change your app's UI and
// functionality as necessary.
Log.i("ReactNative", "Managed Configuration Changed");
sendConfigChanged(managedConfig);
}
}
};
public static NotificationsLifecycleFacade getInstance() {
if (instance == null) {
instance = new NotificationsLifecycleFacade();
}
return instance;
}
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
MattermostManagedModule managedModule = MattermostManagedModule.getInstance();
if (managedModule != null && managedModule.isBlurAppScreenEnabled()) {
activity.getWindow().setFlags(LayoutParams.FLAG_SECURE,
LayoutParams.FLAG_SECURE);
}
if (managedConfig!= null && managedConfig.size() > 0) {
activity.registerReceiver(restrictionsReceiver, restrictionsFilter);
}
}
@Override
public void onActivityResumed(Activity activity) {
switchToVisible(activity);
if (managedConfig != null && managedConfig.size() > 0) {
RestrictionsManager myRestrictionsMgr =
(RestrictionsManager) activity
.getSystemService(Context.RESTRICTIONS_SERVICE);
Bundle newConfig = myRestrictionsMgr.getApplicationRestrictions();
if (!equalBundles(newConfig ,managedConfig)) {
Log.i("ReactNative", "onResumed Managed Configuration Changed");
managedConfig = newConfig;
sendConfigChanged(managedConfig);
}
}
}
@Override
@@ -101,13 +32,6 @@ public class NotificationsLifecycleFacade extends ActivityCallbacks implements A
@Override
public void onActivityStopped(Activity activity) {
switchToInvisible(activity);
if (managedConfig != null && managedConfig.size() > 0) {
try {
activity.unregisterReceiver(restrictionsReceiver);
} catch (IllegalArgumentException e) {
// Just ignore this cause the receiver wasn't registered for this activity
}
}
}
@Override
@@ -145,15 +69,6 @@ public class NotificationsLifecycleFacade extends ActivityCallbacks implements A
mListeners.remove(listener);
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
if (mVisibleActivity != null) {
Intent intent = new Intent("onConfigurationChanged");
intent.putExtra("newConfig", newConfig);
mVisibleActivity.sendBroadcast(intent);
}
}
private synchronized void switchToVisible(Activity activity) {
if (mVisibleActivity == null) {
mVisibleActivity = activity;
@@ -173,67 +88,4 @@ public class NotificationsLifecycleFacade extends ActivityCallbacks implements A
}
}
}
public synchronized void LoadManagedConfig(Activity activity) {
RestrictionsManager myRestrictionsMgr =
(RestrictionsManager) activity
.getSystemService(Context.RESTRICTIONS_SERVICE);
managedConfig = myRestrictionsMgr.getApplicationRestrictions();
myRestrictionsMgr = null;
}
public synchronized Bundle getManagedConfig() {
if (managedConfig!= null && managedConfig.size() > 0) {
return managedConfig;
}
if (mVisibleActivity != null) {
LoadManagedConfig(mVisibleActivity);
return managedConfig;
}
return null;
}
public void sendConfigChanged(Bundle config) {
Object result = Arguments.fromBundle(config);
getRunningReactContext().
getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).
emit("managedConfigDidChange", result);
}
private boolean equalBundles(Bundle one, Bundle two) {
if (one == null || two == null)
return false;
if(one.size() != two.size())
return false;
Set<String> setOne = new ArraySet<String>();
setOne.addAll(one.keySet());
setOne.addAll(two.keySet());
Object valueOne;
Object valueTwo;
for(String key : setOne) {
if (!one.containsKey(key) || !two.containsKey(key))
return false;
valueOne = one.get(key);
valueTwo = two.get(key);
if(valueOne instanceof Bundle && valueTwo instanceof Bundle &&
!equalBundles((Bundle) valueOne, (Bundle) valueTwo)) {
return false;
}
else if(valueOne == null) {
if(valueTwo != null)
return false;
}
else if(!valueOne.equals(valueTwo))
return false;
}
return true;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 925 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.percent.PercentRelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ffffff"
android:gravity="center_horizontal"
tools:context=".SplashScreenActivity">
<ImageView
android:id="@+id/imgLogo"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:src="@drawable/splash" />
</android.support.percent.PercentRelativeLayout>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="white">#FFFFFF</color>
</resources>

View File

@@ -1,20 +1,6 @@
<?xml version="1.0"?>
<resources>
<string name="app_name">Mattermost Beta</string>
<string name="inAppPinCode_title">in-App Pincode</string>
<string name="inAppPinCode_description">Require users to authenticate as the owner of the phone before using the app. Prompts for fingerprint or passcode when the app first opens and when the app has been in the background for more than 5 minutes.</string>
<string name="blurApplicationScreen_title">Blur Application Screen</string>
<string name="blurApplicationScreen_description">Blur the app when its set to background to protect any confidential on-screen information, it also prevents taking screenshots of the app.</string>
<string name="jailbreakProtection_title">Jailbreak &#x2F; Root Detection</string>
<string name="jailbreakProtection_description">Disable app launch on Jailbroken or rooted devices.</string>
<string name="copyAndPasteProtection_title">Copy&amp;Paste Protection</string>
<string name="copyAndPasteProtection_description">Disable the ability to copy from or paste into any text inputs in the app.</string>
<string name="serverUrl_title">Mattermost Server URL</string>
<string name="serverUrl_description">Set a default Mattermost server URL.</string>
<string name="allowOtherServers_title">Allow Other Servers</string>
<string name="allowOtherServers_description">Allow the user to change the above server URL.</string>
<string name="username_title">Default Username</string>
<string name="username_description">Set the username or email address to use to authenticate against the Mattermost Server.</string>
<string name="vendor_title">EMM Vendor or Company Name</string>
<string name="vendor_description">Name of the EMM vendor or company deploying the app. Used in help text when prompting for passcodes so users are aware why the app is being protected.</string>
</resources>

View File

@@ -3,9 +3,7 @@
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowIsTranslucent">false</item>
<item name="android:windowBackground">@color/white</item>
<item name="android:colorBackground">@color/white</item>
<item name="android:windowIsTranslucent">true</item>
</style>
</resources>

View File

@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<restrictions xmlns:android="http://schemas.android.com/apk/res/android">
<restriction
android:key="inAppPinCode"
android:title="@string/inAppPinCode_title"
android:description="@string/inAppPinCode_description"
android:restrictionType="string"
android:defaultValue="false" />
<restriction
android:key="blurApplicationScreen"
android:title="@string/blurApplicationScreen_title"
android:description="@string/blurApplicationScreen_description"
android:restrictionType="string"
android:defaultValue="false" />
<restriction
android:key="jailbreakProtection"
android:title="@string/jailbreakProtection_title"
android:description="@string/jailbreakProtection_description"
android:restrictionType="string"
android:defaultValue="false" />
<restriction
android:key="copyAndPasteProtection"
android:title="@string/copyAndPasteProtection_title"
android:description="@string/copyAndPasteProtection_description"
android:restrictionType="string"
android:defaultValue="false" />
<restriction
android:key="serverUrl"
android:title="@string/serverUrl_title"
android:description="@string/serverUrl_description"
android:restrictionType="string"
android:defaultValue="" />
<restriction
android:key="allowOtherServers"
android:title="@string/allowOtherServers_title"
android:description="@string/allowOtherServers_description"
android:restrictionType="string"
android:defaultValue="true" />
<restriction
android:key="username"
android:title="@string/username_title"
android:description="@string/username_description"
android:restrictionType="string"
android:defaultValue="" />
<restriction
android:key="vendor"
android:title="@string/vendor_title"
android:description="@string/vendor_description"
android:restrictionType="string"
android:defaultValue="" />
</restrictions>

View File

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

View File

@@ -1,20 +1,4 @@
rootProject.name = 'Mattermost'
include ':react-native-doc-viewer'
project(':react-native-doc-viewer').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-doc-viewer/android')
include ':react-native-video'
project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android')
include ':react-native-youtube'
project(':react-native-youtube').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-youtube/android')
include ':react-native-sentry'
project(':react-native-sentry').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-sentry/android')
include ':react-native-exception-handler'
project(':react-native-exception-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-exception-handler/android')
include ':react-native-fetch-blob'
project(':react-native-fetch-blob').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fetch-blob/android')
include ':jail-monkey'
project(':jail-monkey').projectDir = new File(rootProject.projectDir, '../node_modules/jail-monkey/android')
include ':react-native-local-auth'
project(':react-native-local-auth').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-local-auth/android')
include ':react-native-navigation'
project(':react-native-navigation').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-navigation/android/app/')
include ':react-native-image-picker'
@@ -31,9 +15,9 @@ include ':reactnativenotifications'
project(':reactnativenotifications').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-notifications/android')
include ':app'
include ':react-native-svg'
project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android')
include ':react-native-orientation'
project(':react-native-orientation').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-orientation/android')
include ':react-native-linear-gradient'
project(':react-native-linear-gradient').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-linear-gradient/android')
include ':react-native-svg'
project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android')

View File

@@ -1,4 +0,0 @@
{
"name": "Mattermost",
"displayName": "Mattermost"
}

View File

@@ -1,55 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {Dimensions} from 'react-native';
import {DeviceTypes} from 'app/constants';
export function calculateDeviceDimensions() {
const {height, width} = Dimensions.get('window');
return {
type: DeviceTypes.DEVICE_DIMENSIONS_CHANGED,
data: {
deviceHeight: height,
deviceWidth: width
}
};
}
export function connection(isOnline) {
return async (dispatch, getState) => {
dispatch({
type: DeviceTypes.CONNECTION_CHANGED,
data: isOnline
}, getState);
};
}
export function setStatusBarHeight(height = 20) {
return {
type: DeviceTypes.STATUSBAR_HEIGHT_CHANGED,
data: height
};
}
export function setDeviceOrientation(orientation) {
return {
type: DeviceTypes.DEVICE_ORIENTATION_CHANGED,
data: orientation
};
}
export function setDeviceAsTablet() {
return {
type: DeviceTypes.DEVICE_TYPE_CHANGED,
data: true
};
}
export default {
calculateDeviceDimensions,
connection,
setDeviceOrientation,
setDeviceAsTablet,
setStatusBarHeight
};

View File

@@ -11,12 +11,12 @@ export function handleUpdateUserNotifyProps(notifyProps) {
const config = state.entities.general.config;
const {currentUserId} = state.entities.users;
const {interval, user_id, ...otherProps} = notifyProps;
const {interval, ...otherProps} = notifyProps;
const email = notifyProps.email;
if (config.EnableEmailBatching === 'true' && email !== 'false') {
const emailInterval = [{
user_id,
user_id: notifyProps.user_id,
category: Preferences.CATEGORY_NOTIFICATIONS,
name: Preferences.EMAIL_INTERVAL,
value: interval

View File

@@ -8,32 +8,28 @@ import {ViewTypes} from 'app/constants';
import {UserTypes} from 'mattermost-redux/action_types';
import {
fetchMyChannelsAndMembers,
markChannelAsRead,
getChannelStats,
selectChannel,
leaveChannel as serviceLeaveChannel
} from 'mattermost-redux/actions/channels';
import {getPosts, getPostsBefore, getPostsSince, getPostThread} from 'mattermost-redux/actions/posts';
import {getPosts, getPostsBefore, getPostsSince} from 'mattermost-redux/actions/posts';
import {getFilesForPost} from 'mattermost-redux/actions/files';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {savePreferences, deletePreferences} from 'mattermost-redux/actions/preferences';
import {getTeamMembersByIds} from 'mattermost-redux/actions/teams';
import {getProfilesInChannel} from 'mattermost-redux/actions/users';
import {General, Preferences} from 'mattermost-redux/constants';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {
getChannelByName,
getDirectChannelName,
getUserIdFromChannelName,
isDirectChannelVisible,
isGroupChannelVisible,
isDirectChannel,
isGroupChannel
} from 'mattermost-redux/utils/channel_utils';
import {getLastCreateAt} from 'mattermost-redux/utils/post_utils';
import {getPreferencesByCategory} from 'mattermost-redux/utils/preference_utils';
import {isDirectChannelVisible, isGroupChannelVisible} from 'app/utils/channels';
const MAX_POST_TRIES = 3;
export function loadChannelsIfNecessary(teamId) {
return async (dispatch, getState) => {
await fetchMyChannelsAndMembers(teamId)(dispatch, getState);
@@ -43,7 +39,7 @@ export function loadChannelsIfNecessary(teamId) {
export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
return async (dispatch, getState) => {
const state = getState();
const {currentUserId, profilesInChannel} = state.entities.users;
const {currentUserId} = state.entities.users;
const {channels, myMembers} = state.entities.channels;
const {myPreferences} = state.entities.preferences;
const {membersInTeam} = state.entities.teams;
@@ -100,8 +96,7 @@ export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
}
for (const [key, pref] of gmPrefs) {
//only load the profiles in channels if we don't already have them
if (pref.value === 'true' && !profilesInChannel[key]) {
if (pref.value === 'true') {
loadProfilesForChannels.push(key);
}
}
@@ -141,79 +136,32 @@ export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
};
}
export function loadPostsIfNecessaryWithRetry(channelId) {
export function loadPostsIfNecessary(channelId) {
return async (dispatch, getState) => {
const state = getState();
const {posts, postsInChannel} = state.entities.posts;
const postsIds = postsInChannel[channelId];
const time = Date.now();
let received;
// 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) {
// Get the first page of posts if it appears we haven't gotten it yet, like the webapp
received = await retryGetPostsAction(getPosts(channelId), dispatch, getState);
} else {
const {lastConnectAt} = state.device.websocket;
const lastGetPosts = state.views.channel.lastGetPosts[channelId];
let since;
if (lastGetPosts && lastGetPosts < lastConnectAt) {
// Since the websocket disconnected, we may have missed some posts since then
since = lastGetPosts;
} else {
// Trust that we've received all posts since the last time the websocket disconnected
// so just get any that have changed since the latest one we've received
const postsForChannel = postsIds.map((id) => posts[id]);
since = getLastCreateAt(postsForChannel);
}
received = await retryGetPostsAction(getPostsSince(channelId, since), dispatch, getState);
return getPosts(channelId)(dispatch, getState);
}
if (received) {
dispatch({
type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
channelId,
time
});
}
const postsForChannel = postsIds.map((id) => posts[id]);
const latestPostTime = getLastCreateAt(postsForChannel);
return getPostsSince(channelId, latestPostTime)(dispatch, getState);
};
}
export async function retryGetPostsAction(action, dispatch, getState, maxTries = MAX_POST_TRIES) {
for (let i = 0; i < maxTries; i++) {
const {data} = await action(dispatch, getState);
if (data) {
dispatch(setChannelRetryFailed(false));
return data;
}
}
dispatch(setChannelRetryFailed(true));
return null;
}
export function loadFilesForPostIfNecessary(postId) {
export function loadFilesForPostIfNecessary(post) {
return async (dispatch, getState) => {
const {files} = getState().entities;
const fileIdsForPost = files.fileIdsByPostId[postId];
const fileIdsForPost = files.fileIdsByPostId[post.id];
if (!fileIdsForPost) {
await getFilesForPost(postId)(dispatch, getState);
}
};
}
export function loadThreadIfNecessary(rootId, channelId) {
return async (dispatch, getState) => {
const state = getState();
const {posts, postsInChannel} = state.entities.posts;
const channelPosts = postsInChannel[channelId];
if (rootId && (!posts[rootId] || !channelPosts || !channelPosts[rootId])) {
getPostThread(rootId, false)(dispatch, getState);
await getFilesForPost(post.id)(dispatch, getState);
}
};
}
@@ -221,43 +169,34 @@ export function loadThreadIfNecessary(rootId, channelId) {
export function selectInitialChannel(teamId) {
return async (dispatch, getState) => {
const state = getState();
const {channels, myMembers} = state.entities.channels;
const {channels, currentChannelId, myMembers} = state.entities.channels;
const {currentUserId} = state.entities.users;
const currentChannel = channels[currentChannelId];
const {myPreferences} = state.entities.preferences;
const lastChannelForTeam = state.views.team.lastChannelForTeam[teamId];
const lastChannelId = lastChannelForTeam && lastChannelForTeam.length ? lastChannelForTeam[0] : '';
const lastChannel = channels[lastChannelId];
const isDMVisible = lastChannel && lastChannel.type === General.DM_CHANNEL &&
isDirectChannelVisible(currentUserId, myPreferences, lastChannel);
const isDMVisible = currentChannel && currentChannel.type === General.DM_CHANNEL &&
isDirectChannelVisible(currentUserId, myPreferences, currentChannel);
const isGMVisible = lastChannel && lastChannel.type === General.GM_CHANNEL &&
isGroupChannelVisible(myPreferences, lastChannel);
const isGMVisible = currentChannel && currentChannel.type === General.GM_CHANNEL &&
isGroupChannelVisible(myPreferences, currentChannel);
if (lastChannelId && myMembers[lastChannelId] &&
(lastChannel.team_id === teamId || isDMVisible || isGMVisible)) {
handleSelectChannel(lastChannelId)(dispatch, getState);
markChannelAsRead(lastChannelId)(dispatch, getState);
if (currentChannel && myMembers[currentChannelId] &&
(currentChannel.team_id === teamId || isDMVisible || isGMVisible)) {
await handleSelectChannel(currentChannelId)(dispatch, getState);
return;
}
const channel = Object.values(channels).find((c) => c.team_id === teamId && c.name === General.DEFAULT_CHANNEL);
let channelId;
if (channel) {
channelId = channel.id;
dispatch(setChannelDisplayName(''));
await handleSelectChannel(channel.id)(dispatch, getState);
} else {
// Handle case when the default channel cannot be found
// so we need to get the first available channel of the team
const channelsInTeam = Object.values(channels).filter((c) => c.team_id === teamId);
const firstChannel = channelsInTeam.length ? channelsInTeam[0].id : {id: ''};
channelId = firstChannel.id;
}
if (channelId) {
dispatch(setChannelDisplayName(''));
handleSelectChannel(channelId)(dispatch, getState);
markChannelAsRead(channelId)(dispatch, getState);
await handleSelectChannel(firstChannel.id)(dispatch, getState);
}
};
}
@@ -266,84 +205,26 @@ export function handleSelectChannel(channelId) {
return async (dispatch, getState) => {
const {currentTeamId} = getState().entities.teams;
loadPostsIfNecessaryWithRetry(channelId)(dispatch, getState);
dispatch({
type: ViewTypes.SET_LAST_CHANNEL_FOR_TEAM,
teamId: currentTeamId,
channelId
});
getChannelStats(channelId)(dispatch, getState);
selectChannel(channelId)(dispatch, getState);
dispatch(batchActions([
{
type: ViewTypes.SET_INITIAL_POST_VISIBILITY,
data: channelId
},
setChannelLoading(false),
{
type: ViewTypes.SET_LAST_CHANNEL_FOR_TEAM,
teamId: currentTeamId,
channelId
}
]), 'BATCH_CHANNEL_LOADED');
};
}
export function handlePostDraftChanged(channelId, draft) {
export function handlePostDraftChanged(channelId, postDraft) {
return async (dispatch, getState) => {
dispatch({
type: ViewTypes.POST_DRAFT_CHANGED,
channelId,
draft
postDraft
}, getState);
};
}
export function handlePostDraftSelectionChanged(channelId, cursorPosition) {
return {
type: ViewTypes.POST_DRAFT_SELECTION_CHANGED,
channelId,
cursorPosition
};
}
export function insertToDraft(value) {
return (dispatch, getState) => {
const state = getState();
const channelId = getCurrentChannelId(state);
const threadId = state.entities.posts.selectedPostId;
let draft;
let cursorPosition;
let action;
if (state.views.thread.drafts[threadId]) {
const threadDraft = state.views.thread.drafts[threadId];
draft = threadDraft.draft;
cursorPosition = threadDraft.cursorPosition;
action = {
type: ViewTypes.COMMENT_DRAFT_CHANGED,
rootId: threadId
};
} else if (state.views.channel.drafts[channelId]) {
const channelDraft = state.views.channel.drafts[channelId];
draft = channelDraft.draft;
cursorPosition = channelDraft.cursorPosition;
action = {
type: ViewTypes.POST_DRAFT_CHANGED,
channelId
};
}
let nextDraft = `${value}`;
if (cursorPosition > 0) {
const beginning = draft.slice(0, cursorPosition);
const end = draft.slice(cursorPosition);
nextDraft = `${beginning}${value}${end}`;
}
if (action && nextDraft !== draft) {
dispatch({
...action,
draft: nextDraft
});
}
};
}
export function toggleDMChannel(otherUserId, visible) {
return async (dispatch, getState) => {
const state = getState();
@@ -379,10 +260,13 @@ export function toggleGMChannel(channelId, visible) {
export function closeDMChannel(channel) {
return async (dispatch, getState) => {
const state = getState();
const currentChannelId = getCurrentChannelId(state);
if (channel.isFavorite) {
unmarkFavorite(channel.id)(dispatch, getState);
}
toggleDMChannel(channel.teammate_id, 'false')(dispatch, getState);
if (channel.id === currentChannelId) {
if (channel.isCurrent) {
selectInitialChannel(state.entities.teams.currentTeamId)(dispatch, getState);
}
};
@@ -391,21 +275,50 @@ export function closeDMChannel(channel) {
export function closeGMChannel(channel) {
return async (dispatch, getState) => {
const state = getState();
const currentChannelId = getCurrentChannelId(state);
if (channel.isFavorite) {
unmarkFavorite(channel.id)(dispatch, getState);
}
toggleGMChannel(channel.id, 'false')(dispatch, getState);
if (channel.id === currentChannelId) {
if (channel.isCurrent) {
selectInitialChannel(state.entities.teams.currentTeamId)(dispatch, getState);
}
};
}
export function refreshChannelWithRetry(channelId) {
export function markFavorite(channelId) {
return async (dispatch, getState) => {
dispatch(setChannelRefreshing(true));
const posts = await retryGetPostsAction(getPosts(channelId), dispatch, getState);
const {currentUserId} = getState().entities.users;
const fav = [{
user_id: currentUserId,
category: Preferences.CATEGORY_FAVORITE_CHANNEL,
name: channelId,
value: 'true'
}];
savePreferences(currentUserId, fav)(dispatch, getState);
};
}
export function unmarkFavorite(channelId) {
return async (dispatch, getState) => {
const {currentUserId} = getState().entities.users;
const fav = [{
user_id: currentUserId,
category: Preferences.CATEGORY_FAVORITE_CHANNEL,
name: channelId
}];
deletePreferences(currentUserId, fav)(dispatch, getState);
};
}
export function refreshChannel(channelId) {
return async (dispatch, getState) => {
dispatch(setChannelRefreshing());
await getPosts(channelId)(dispatch, getState);
dispatch(setChannelRefreshing(false));
return posts;
};
}
@@ -426,17 +339,17 @@ export function setChannelLoading(loading = true) {
};
}
export function setChannelRefreshing(loading = true) {
export function setChannelRefreshing(refreshing = true) {
return {
type: ViewTypes.SET_CHANNEL_REFRESHING,
loading
refreshing
};
}
export function setChannelRetryFailed(failed = true) {
export function setPostTooltipVisible(visible = true) {
return {
type: ViewTypes.SET_CHANNEL_RETRY_FAILED,
failed
type: ViewTypes.POST_TOOLTIP_VISIBLE,
visible
};
}
@@ -455,42 +368,26 @@ export function increasePostVisibility(channelId, focusedPostId) {
const currentPostVisibility = postVisibility[channelId] || 0;
if (loadingPosts[channelId]) {
return;
return true;
}
// Check if we already have the posts that we want to show
if (!focusedPostId) {
const loadedPostCount = state.entities.posts.postsInChannel[channelId].length;
const desiredPostVisibility = currentPostVisibility + ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
if (loadedPostCount >= desiredPostVisibility) {
// We already have the posts, so we just need to show them
dispatch({
type: ViewTypes.INCREASE_POST_VISIBILITY,
data: channelId,
amount: ViewTypes.POST_VISIBILITY_CHUNK_SIZE
});
return;
dispatch(batchActions([
{
type: ViewTypes.LOADING_POSTS,
data: true,
channelId
}
}
dispatch({
type: ViewTypes.LOADING_POSTS,
data: true,
channelId
});
]));
const page = Math.floor(currentPostVisibility / ViewTypes.POST_VISIBILITY_CHUNK_SIZE);
let result;
let posts;
if (focusedPostId) {
result = await getPostsBefore(channelId, focusedPostId, page, ViewTypes.POST_VISIBILITY_CHUNK_SIZE)(dispatch, getState);
posts = await getPostsBefore(channelId, focusedPostId, page, ViewTypes.POST_VISIBILITY_CHUNK_SIZE)(dispatch, getState);
} else {
result = await getPosts(channelId, page, ViewTypes.POST_VISIBILITY_CHUNK_SIZE)(dispatch, getState);
posts = await getPosts(channelId, page, ViewTypes.POST_VISIBILITY_CHUNK_SIZE)(dispatch, getState);
}
const posts = result.data;
if (posts) {
// make sure to increment the posts visibility
// only if we got results
@@ -506,5 +403,7 @@ export function increasePostVisibility(channelId, focusedPostId) {
data: false,
channelId
});
return posts && posts.order.length >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
};
}

View File

@@ -8,9 +8,9 @@ export function handleAddChannelMembers(channelId, members) {
try {
const requests = members.map((m) => dispatch(addChannelMember(channelId, m, getState)));
return await Promise.all(requests);
await Promise.all(requests);
} catch (error) {
return error;
// should be handled by global error handling
}
};
}

View File

@@ -8,9 +8,9 @@ export function handleRemoveChannelMembers(channelId, members) {
try {
const requests = members.map((m) => dispatch(removeChannelMember(channelId, m, getState)));
return await Promise.all(requests);
await Promise.all(requests);
} catch (error) {
return error;
// should be handled by global error handling
}
};
}

View File

@@ -1,10 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {ViewTypes} from 'app/constants';
export function setLastUpgradeCheck() {
return {
type: ViewTypes.SET_LAST_UPGRADE_CHECK
};
}

View File

@@ -1,32 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {executeCommand as executeCommandService} from 'mattermost-redux/actions/integrations';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
export function executeCommand(message, channelId, rootId) {
return async (dispatch, getState) => {
const state = getState();
const teamId = getCurrentTeamId(state);
const args = {
channel_id: channelId,
team_id: teamId,
root_id: rootId,
parent_id: rootId
};
let msg = message;
let cmdLength = msg.indexOf(' ');
if (cmdLength < 0) {
cmdLength = msg.length;
}
const cmd = msg.substring(0, cmdLength).toLowerCase();
msg = cmd + msg.substring(cmdLength, msg.length);
return await executeCommandService(msg, args)(dispatch, getState);
};
}

View File

@@ -3,11 +3,11 @@
import {ViewTypes} from 'app/constants';
export function handleSearchDraftChanged(text) {
export function connection(isOnline) {
return async (dispatch, getState) => {
dispatch({
type: ViewTypes.SEARCH_DRAFT_CHANGED,
text
type: ViewTypes.CONNECTION_CHANGED,
data: isOnline
}, getState);
};
}

View File

@@ -12,7 +12,7 @@ export function handleCreateChannel(displayName, purpose, header, type) {
const state = getState();
const currentUserId = getCurrentUserId(state);
const teamId = getCurrentTeamId(state);
const channel = {
let channel = {
team_id: teamId,
name: cleanUpUrlable(displayName),
display_name: displayName,
@@ -21,10 +21,10 @@ export function handleCreateChannel(displayName, purpose, header, type) {
type
};
const {data} = await createChannel(channel, currentUserId)(dispatch, getState);
if (data && data.id) {
channel = await createChannel(channel, currentUserId)(dispatch, getState);
if (channel && channel.id) {
dispatch(setChannelDisplayName(displayName));
handleSelectChannel(data.id)(dispatch, getState);
handleSelectChannel(channel.id)(dispatch, getState);
}
};
}

View File

@@ -1,34 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {addReaction as serviceAddReaction} from 'mattermost-redux/actions/posts';
import {getPostIdsInCurrentChannel, makeGetPostIdsForThread} from 'mattermost-redux/selectors/entities/posts';
import {ViewTypes} from 'app/constants';
const getPostIdsForThread = makeGetPostIdsForThread();
export function addReaction(postId, emoji) {
return (dispatch) => {
dispatch(serviceAddReaction(postId, emoji));
dispatch(addRecentEmoji(emoji));
};
}
export function addReactionToLatestPost(emoji, rootId) {
return async (dispatch, getState) => {
const state = getState();
const postIds = rootId ? getPostIdsForThread(state, rootId) : getPostIdsInCurrentChannel(state);
const lastPostId = postIds[0];
dispatch(serviceAddReaction(lastPostId, emoji));
dispatch(addRecentEmoji(emoji));
};
}
export function addRecentEmoji(emoji) {
return {
type: ViewTypes.ADD_RECENT_EMOJI,
emoji
};
}

View File

@@ -16,32 +16,23 @@ export function handleUploadFiles(files, rootId) {
const channelId = state.entities.channels.currentChannelId;
const formData = new FormData();
const clientIds = [];
const re = /heic/i;
files.forEach((file) => {
let name = file.fileName;
let mimeType = lookupMimeType(name);
let extension = name.split('.').pop().replace('.', '');
const uri = file.uri;
const mimeType = lookupMimeType(file.fileName);
const extension = file.fileName.split('.').pop().replace('.', '');
const clientId = generateId();
if (re.test(extension)) {
extension = 'JPG';
name = name.replace(re, 'jpg');
mimeType = 'image/jpeg';
}
clientIds.push({
clientId,
localPath: uri,
name,
localPath: file.uri,
name: file.fileName,
type: mimeType,
extension
});
const fileData = {
uri,
name,
uri: file.uri,
name: file.fileName,
type: mimeType,
extension
};
@@ -108,14 +99,6 @@ export function handleClearFiles(channelId, rootId) {
};
}
export function handleClearFailedFiles(channelId, rootId) {
return {
type: ViewTypes.CLEAR_FAILED_FILES_FOR_POST_DRAFT,
channelId,
rootId
};
}
export function handleRemoveFile(clientId, channelId, rootId) {
return {
type: ViewTypes.REMOVE_FILE_FROM_POST_DRAFT,

View File

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

View File

@@ -2,9 +2,9 @@
// See License.txt for license information.
import {getDirectChannelName} from 'mattermost-redux/utils/channel_utils';
import {createDirectChannel, createGroupChannel} from 'mattermost-redux/actions/channels';
import {createDirectChannel} from 'mattermost-redux/actions/channels';
import {getProfilesByIds, getStatusesByIds} from 'mattermost-redux/actions/users';
import {handleSelectChannel, toggleDMChannel, toggleGMChannel} from 'app/actions/views/channel';
import {handleSelectChannel, toggleDMChannel} from 'app/actions/views/channel';
export function makeDirectChannel(otherUserId) {
return async (dispatch, getState) => {
@@ -12,45 +12,21 @@ 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);
} else {
result = await createDirectChannel(currentUserId, otherUserId)(dispatch, getState);
channel = result.data;
}
if (channel) {
handleSelectChannel(channel.id)(dispatch, getState);
return true;
}
const created = await createDirectChannel(currentUserId, otherUserId)(dispatch, getState);
if (created.data) {
handleSelectChannel(created.data.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;
return created;
};
}

View File

@@ -1,130 +1,80 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {GeneralTypes, PostTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import {General} from 'mattermost-redux/constants';
import {markChannelAsRead} from 'mattermost-redux/actions/channels';
import {getClientConfig, getDataRetentionPolicy, getLicenseConfig} from 'mattermost-redux/actions/general';
import {getPosts} from 'mattermost-redux/actions/posts';
import {getMyTeams, getMyTeamMembers, selectTeam} from 'mattermost-redux/actions/teams';
import {recordTime} from 'app/utils/segment';
import {ViewTypes} from 'app/constants';
import {
handleSelectChannel,
setChannelDisplayName,
retryGetPostsAction
loadChannelsIfNecessary,
loadProfilesAndTeamMembersForDMSidebar,
setChannelDisplayName
} from 'app/actions/views/channel';
import {handleTeamChange, selectFirstAvailableTeam} from 'app/actions/views/select_team';
export function loadConfigAndLicense() {
import {getClientConfig, getLicenseConfig, setServerVersion} from 'mattermost-redux/actions/general';
import {markChannelAsRead, viewChannel} from 'mattermost-redux/actions/channels';
export function loadConfigAndLicense(serverVersion) {
return async (dispatch, getState) => {
const [configData, licenseData] = await Promise.all([
getClientConfig()(dispatch, getState),
getLicenseConfig()(dispatch, getState)
]);
const config = configData.data || {};
const license = licenseData.data || {};
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
license.IsLicensed === 'true' && license.DataRetention === 'true') {
getDataRetentionPolicy()(dispatch, getState);
} else {
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
}
return {config, license};
getClientConfig()(dispatch, getState);
getLicenseConfig()(dispatch, getState);
setServerVersion(serverVersion)(dispatch, getState);
};
}
export function loadFromPushNotification(notification) {
export function queueNotification(notification) {
return async (dispatch, getState) => {
dispatch({type: ViewTypes.NOTIFICATION_CHANGED, data: notification}, getState);
};
}
export function clearNotification() {
return async (dispatch, getState) => {
dispatch({type: ViewTypes.NOTIFICATION_CHANGED, data: null}, getState);
};
}
export function goToNotification(notification) {
return async (dispatch, getState) => {
const state = getState();
const {data} = notification;
const {currentTeamId, teams, myMembers: myTeamMembers} = state.entities.teams;
const {currentTeamId, teams} = state.entities.teams;
const {currentChannelId} = state.entities.channels;
const channelId = data.channel_id;
// when the notification does not have a team id is because its from a DM or GM
const teamId = data.team_id || currentTeamId;
// if the notification does not have a team id is because its from a DM or GM
let teamId = data.team_id || currentTeamId;
//verify that we have the team loaded
if (teamId && (!teams[teamId] || !myTeamMembers[teamId])) {
await Promise.all([
getMyTeams()(dispatch, getState),
getMyTeamMembers()(dispatch, getState)
]);
}
dispatch(setChannelDisplayName(''));
// when the notification is from a team other than the current team
if (teamId !== currentTeamId) {
selectTeam({id: teamId})(dispatch, getState);
}
// when the notification is from the same channel as the current channel
// we should get the posts
if (channelId === currentChannelId) {
markChannelAsRead(channelId, null, false)(dispatch, getState);
await retryGetPostsAction(getPosts(channelId), dispatch, getState);
if (teamId) {
handleTeamChange(teams[teamId])(dispatch, getState);
await loadChannelsIfNecessary(teamId)(dispatch, getState);
} else {
// when the notification is from a channel other than the current channel
markChannelAsRead(channelId, currentChannelId, false)(dispatch, getState);
dispatch(setChannelDisplayName(''));
await selectFirstAvailableTeam()(dispatch, getState);
teamId = state.entities.team.currentTeamId;
}
viewChannel(channelId)(dispatch, getState);
loadProfilesAndTeamMembersForDMSidebar(teamId)(dispatch, getState);
if (channelId !== currentChannelId) {
handleSelectChannel(channelId)(dispatch, getState);
}
markChannelAsRead(channelId, currentChannelId)(dispatch, getState);
};
}
export function purgeOfflineStore() {
return {type: General.OFFLINE_STORE_PURGE};
}
export function createPost(post) {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = state.entities.users.currentUserId;
const timestamp = Date.now();
const pendingPostId = post.pending_post_id || `${currentUserId}:${timestamp}`;
const newPost = {
...post,
pending_post_id: pendingPostId,
create_at: timestamp,
update_at: timestamp
};
try {
const payload = Client4.createPost({...newPost, create_at: 0});
dispatch({
type: PostTypes.RECEIVED_POSTS,
data: {
order: [],
posts: {
[payload.id]: payload
}
},
channelId: payload.channel_id
});
} catch (error) {
return {error};
}
return {data: true};
};
}
export function recordLoadTime(screenName, category) {
return async (dispatch, getState) => {
const {currentUserId} = getState().entities.users;
recordTime(screenName, category, currentUserId);
export function setStatusBarHeight(height = 20) {
return {
type: ViewTypes.STATUSBAR_HEIGHT_CHANGED,
data: height
};
}
export default {
loadConfigAndLicense,
loadFromPushNotification,
purgeOfflineStore
queueNotification,
clearNotification,
goToNotification,
setStatusBarHeight
};

View File

@@ -3,39 +3,29 @@
import {batchActions} from 'redux-batched-actions';
import {markChannelAsRead, markChannelAsViewed} from 'mattermost-redux/actions/channels';
import {ChannelTypes, TeamTypes} from 'mattermost-redux/action_types';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {NavigationTypes} from 'app/constants';
import {setChannelDisplayName} from './channel';
export function handleTeamChange(teamId, selectChannel = true) {
export function handleTeamChange(team) {
return async (dispatch, getState) => {
const state = getState();
const {currentTeamId} = state.entities.teams;
if (currentTeamId === teamId) {
const {currentTeamId} = getState().entities.teams;
if (currentTeamId === team.id) {
return;
}
const actions = [
setChannelDisplayName(''),
{type: TeamTypes.SELECT_TEAM, data: teamId}
];
const state = getState();
const lastChannelId = state.views.team.lastChannelForTeam[team.id] || '';
if (selectChannel) {
actions.push({type: ChannelTypes.SELECT_CHANNEL, data: ''});
dispatch(setChannelDisplayName(''), getState);
const lastChannels = state.views.team.lastChannelForTeam[teamId] || [];
const lastChannelId = lastChannels[0] || '';
const currentChannelId = getCurrentChannelId(state);
markChannelAsViewed(currentChannelId)(dispatch, getState);
markChannelAsRead(lastChannelId, currentChannelId)(dispatch, getState);
}
dispatch(batchActions(actions), getState);
dispatch(batchActions([
{type: TeamTypes.SELECT_TEAM, data: team.id},
{type: ChannelTypes.SELECT_CHANNEL, data: lastChannelId}
]), getState);
};
}
@@ -46,7 +36,7 @@ export function selectFirstAvailableTeam() {
const firstTeam = Object.values(teams).sort((a, b) => a.display_name.localeCompare(b.display_name))[0];
if (firstTeam) {
handleTeamChange(firstTeam.id)(dispatch, getState);
handleTeamChange(firstTeam)(dispatch, getState);
} else {
EventEmitter.emit(NavigationTypes.NAVIGATION_NO_TEAMS);
}

View File

@@ -12,11 +12,3 @@ export function handleCommentDraftChanged(rootId, draft) {
}, getState);
};
}
export function handleCommentDraftSelectionChanged(rootId, cursorPosition) {
return {
type: ViewTypes.COMMENT_DRAFT_SELECTION_CHANGED,
rootId,
cursorPosition
};
}

View File

@@ -3,80 +3,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Clipboard, Text} from 'react-native';
import {intlShape} from 'react-intl';
import {Text} from 'react-native';
import CustomPropTypes from 'app/constants/custom_prop_types';
import mattermostManaged from 'app/mattermost_managed';
export default class AtMention extends React.PureComponent {
static propTypes = {
isSearchResult: PropTypes.bool,
mentionName: PropTypes.string.isRequired,
mentionStyle: CustomPropTypes.Style,
navigator: PropTypes.object.isRequired,
onLongPress: PropTypes.func.isRequired,
onPostPress: PropTypes.func,
textStyle: CustomPropTypes.Style,
theme: PropTypes.object.isRequired,
usersByUsername: PropTypes.object.isRequired
};
static contextTypes = {
intl: intlShape
}
constructor(props) {
super(props);
const userDetails = this.getUserDetailsFromMentionName(props);
this.state = {
username: userDetails.username,
id: userDetails.id
username: this.getUsernameFromMentionName(props)
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.mentionName !== this.props.mentionName || nextProps.usersByUsername !== this.props.usersByUsername) {
const userDetails = this.getUserDetailsFromMentionName(nextProps);
this.setState({
username: userDetails.username,
id: userDetails.id
username: this.getUsernameFromMentionName(nextProps)
});
}
}
goToUserProfile = () => {
const {navigator, theme} = this.props;
const {intl} = this.context;
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) {
getUsernameFromMentionName(props) {
let mentionName = props.mentionName;
while (mentionName.length > 0) {
if (props.usersByUsername.hasOwnProperty(mentionName)) {
const user = props.usersByUsername[mentionName];
return {
username: user.username,
id: user.id
};
if (props.usersByUsername[mentionName]) {
return props.usersByUsername[mentionName].username;
}
// Repeatedly trim off trailing punctuation in case this is at the end of a sentence
@@ -87,52 +47,21 @@ export default class AtMention extends React.PureComponent {
}
}
return {
username: ''
};
}
handleLongPress = async () => {
const {intl} = this.context;
const config = await mattermostManaged.getLocalConfig();
let action;
if (config.copyAndPasteProtection !== 'false') {
action = {
text: intl.formatMessage({
id: 'mobile.mention.copy_mention',
defaultMessage: 'Copy Mention'
}),
onPress: this.handleCopyMention
};
}
this.props.onLongPress(action);
}
handleCopyMention = () => {
const {username} = this.state;
Clipboard.setString(`@${username}`);
return '';
}
render() {
const {isSearchResult, mentionName, mentionStyle, onPostPress, textStyle} = this.props;
const username = this.state.username;
if (!username) {
return <Text style={textStyle}>{'@' + mentionName}</Text>;
return <Text style={this.props.textStyle}>{'@' + this.props.mentionName}</Text>;
}
const suffix = this.props.mentionName.substring(username.length);
return (
<Text
style={textStyle}
onPress={isSearchResult ? onPostPress : this.goToUserProfile}
onLongPress={this.handleLongPress}
>
<Text style={mentionStyle}>
<Text style={this.props.textStyle}>
<Text style={this.props.mentionStyle}>
{'@' + username}
</Text>
{suffix}

View File

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

View File

@@ -1,131 +1,113 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {SectionList} from 'react-native';
import {
ListView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import FormattedText from 'app/components/formatted_text';
import ProfilePicture from 'app/components/profile_picture';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import {RequestStatus} from 'mattermost-redux/constants';
import {AT_MENTION_REGEX, AT_MENTION_SEARCH_REGEX} from 'app/constants/autocomplete';
import AtMentionItem from 'app/components/autocomplete/at_mention_item';
import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divider';
import AutocompleteSectionHeader from 'app/components/autocomplete/autocomplete_section_header';
import SpecialMentionItem from 'app/components/autocomplete/special_mention_item';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
const AT_MENTION_REGEX = /\B(@([^@\r\n\s]*))$/i;
export default class AtMention extends PureComponent {
export default class AtMention extends Component {
static propTypes = {
actions: PropTypes.shape({
autocompleteUsers: PropTypes.func.isRequired
}).isRequired,
currentChannelId: PropTypes.string,
currentUserId: PropTypes.string.isRequired,
currentChannelId: PropTypes.string.isRequired,
currentTeamId: PropTypes.string.isRequired,
cursorPosition: PropTypes.number.isRequired,
defaultChannel: PropTypes.object,
inChannel: PropTypes.array,
isSearch: PropTypes.bool,
listHeight: PropTypes.number,
matchTerm: PropTypes.string,
onChangeText: PropTypes.func.isRequired,
onResultCountChange: PropTypes.func.isRequired,
outChannel: PropTypes.array,
defaultChannel: PropTypes.object.isRequired,
autocompleteUsersInCurrentChannel: PropTypes.object.isRequired,
postDraft: PropTypes.string,
requestStatus: PropTypes.string.isRequired,
teamMembers: PropTypes.array,
theme: PropTypes.object.isRequired,
value: PropTypes.string
onChangeText: PropTypes.func.isRequired,
actions: PropTypes.shape({
autocompleteUsers: PropTypes.func.isRequired
})
};
static defaultProps = {
autocompleteUsersInCurrentChannel: {},
defaultChannel: {},
isSearch: false,
value: ''
postDraft: ''
};
constructor(props) {
super(props);
const ds = new ListView.DataSource({
sectionHeaderHasChanged: (s1, s2) => s1 !== s2,
rowHasChanged: (r1, r2) => r1 !== r2
});
const data = {};
this.state = {
sections: []
active: false,
dataSource: ds.cloneWithRowsAndSections(data)
};
}
componentWillReceiveProps(nextProps) {
const {inChannel, outChannel, teamMembers, isSearch, matchTerm, requestStatus} = nextProps;
if ((matchTerm !== this.props.matchTerm && matchTerm === null) || this.state.mentionComplete) {
// if the term changes but is null or the mention has been completed we render this component as null
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(AT_MENTION_REGEX);
if (!match || this.state.mentionComplete) {
this.setState({
mentionComplete: false,
sections: []
active: false,
matchTerm: null,
mentionComplete: false
});
return;
}
const matchTerm = match[2];
if (matchTerm !== this.state.matchTerm) {
this.setState({
matchTerm
});
this.props.onResultCountChange(0);
return;
} else if (matchTerm === null) {
// if the terms did not change but is null then we don't need to do anything
return;
}
if (matchTerm !== this.props.matchTerm) {
// if the term changed and we haven't made the request do that first
const {currentTeamId, currentChannelId} = this.props;
const channelId = isSearch ? '' : currentChannelId;
this.props.actions.autocompleteUsers(matchTerm, currentTeamId, channelId);
return;
this.props.actions.autocompleteUsers(matchTerm, currentTeamId, currentChannelId);
}
if (requestStatus !== RequestStatus.STARTED &&
(inChannel !== this.props.inChannel || outChannel !== this.props.outChannel || teamMembers !== this.props.teamMembers)) {
// if the request is complete and the term is not null we show the autocomplete
const sections = [];
if (isSearch) {
sections.push({
id: 'mobile.suggestion.members',
defaultMessage: 'Members',
data: teamMembers,
key: 'teamMembers'
});
} else {
if (inChannel.length) {
sections.push({
id: 'suggestion.mention.members',
defaultMessage: 'Channel Members',
data: inChannel,
key: 'inChannel'
});
}
if (nextProps.requestStatus !== RequestStatus.STARTED) {
const membersInChannel = this.filter(nextProps.autocompleteUsersInCurrentChannel.inChannel, matchTerm) || [];
const membersOutOfChannel = this.filter(nextProps.autocompleteUsersInCurrentChannel.outChannel, matchTerm) || [];
if (this.checkSpecialMentions(matchTerm)) {
sections.push({
id: 'suggestion.mention.special',
defaultMessage: 'Special Mentions',
data: this.getSpecialMentions(),
key: 'special',
renderItem: this.renderSpecialMentions
});
}
if (outChannel.length) {
sections.push({
id: 'suggestion.mention.nonmembers',
defaultMessage: 'Not in Channel',
data: outChannel,
key: 'outChannel'
});
}
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});
}
this.setState({
sections
active: data.hasOwnProperty('inChannel') || data.hasOwnProperty('specialMentions') || data.hasOwnProperty('notInChannel'),
dataSource: this.state.dataSource.cloneWithRowsAndSections(data)
});
this.props.onResultCountChange(sections.reduce((total, section) => total + section.data.length, 0));
}
}
keyExtractor = (item) => {
return item.id || item;
filter = (profiles, matchTerm) => {
return profiles.filter((p) => {
return ((p.id !== this.props.currentUserId) && (
p.username.toLowerCase().includes(matchTerm) || p.email.toLowerCase().includes(matchTerm) ||
p.first_name.toLowerCase().includes(matchTerm) || p.last_name.toLowerCase().includes(matchTerm)));
});
};
getSpecialMentions = () => {
@@ -152,90 +134,217 @@ export default class AtMention extends PureComponent {
};
completeMention = (mention) => {
const {cursorPosition, isSearch, onChangeText, value} = this.props;
const mentionPart = value.substring(0, cursorPosition);
const mentionPart = this.props.postDraft.substring(0, this.props.cursorPosition);
let completedDraft;
if (isSearch) {
completedDraft = mentionPart.replace(AT_MENTION_SEARCH_REGEX, `from: ${mention} `);
} else {
completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `);
let completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `);
if (this.props.postDraft.length > this.props.cursorPosition) {
completedDraft += this.props.postDraft.substring(this.props.cursorPosition);
}
if (value.length > cursorPosition) {
completedDraft += value.substring(cursorPosition);
}
onChangeText(completedDraft, true);
this.setState({mentionComplete: true});
this.props.onChangeText(completedDraft);
this.setState({
active: false,
mentionComplete: true
});
};
renderSectionHeader = ({section}) => {
renderSectionHeader = (sectionData, sectionId) => {
const style = getStyleFromTheme(this.props.theme);
const localization = {
inChannel: {
id: 'suggestion.mention.members',
defaultMessage: 'Channel Members'
},
notInChannel: {
id: 'suggestion.mention.nonmembers',
defaultMessage: 'Not in Channel'
},
specialMentions: {
id: 'suggestion.mention.special',
defaultMessage: 'Special Mentions'
}
};
return (
<AutocompleteSectionHeader
id={section.id}
defaultMessage={section.defaultMessage}
theme={this.props.theme}
/>
<View style={style.sectionWrapper}>
<View style={style.section}>
<FormattedText
id={localization[sectionId].id}
defaultMessage={localization[sectionId].defaultMessage}
style={style.sectionText}
/>
</View>
</View>
);
};
renderItem = ({item}) => {
renderRow = (data, sectionId) => {
if (sectionId === 'specialMentions') {
return this.renderSpecialMentions(data);
}
const style = getStyleFromTheme(this.props.theme);
const hasFullName = data.first_name.length > 0 && data.last_name.length > 0;
return (
<AtMentionItem
onPress={this.completeMention}
userId={item}
/>
<TouchableOpacity
onPress={() => this.completeMention(data.username)}
style={style.row}
>
<View style={style.rowPicture}>
<ProfilePicture
user={data}
theme={this.props.theme}
size={20}
status={null}
/>
</View>
<Text style={style.rowUsername}>{`@${data.username}`}</Text>
{hasFullName && <Text style={style.rowUsername}>{' - '}</Text>}
{hasFullName && <Text style={style.rowFullname}>{`${data.first_name} ${data.last_name}`}</Text>}
</TouchableOpacity>
);
};
renderSpecialMentions = ({item}) => {
renderSpecialMentions = (data) => {
const style = getStyleFromTheme(this.props.theme);
return (
<SpecialMentionItem
completeHandle={item.completeHandle}
defaultMessage={item.defaultMessage}
id={item.id}
onPress={this.completeMention}
theme={this.props.theme}
values={item.values}
/>
<TouchableOpacity
onPress={() => this.completeMention(data.completeHandle)}
style={style.row}
>
<View style={style.rowPicture}>
<Icon
name='users'
style={style.rowIcon}
/>
</View>
<Text style={style.textWrapper}>
<Text style={style.rowUsername}>{`@${data.completeHandle}`}</Text>
<Text style={style.rowUsername}>{' - '}</Text>
<FormattedText
id={data.id}
defaultMessage={data.defaultMessage}
values={data.values}
style={[style.rowFullname, {flex: 1}]}
/>
</Text>
</TouchableOpacity>
);
};
render() {
const {isSearch, listHeight, theme} = this.props;
const {mentionComplete, sections} = this.state;
if (sections.length === 0 || mentionComplete) {
// If we are not in an active state or the mention has been completed return null so nothing is rendered
const {autocompleteUsersInCurrentChannel, 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.
return null;
}
const style = getStyleFromTheme(theme);
const style = getStyleFromTheme(this.props.theme);
if (
!autocompleteUsersInCurrentChannel.inChannel &&
!autocompleteUsersInCurrentChannel.outChannel &&
requestStatus === RequestStatus.STARTED
) {
return (
<View style={style.loading}>
<FormattedText
id='analytics.chart.loading": "Loading...'
defaultMessage='Loading...'
style={style.sectionText}
/>
</View>
);
}
return (
<SectionList
<ListView
keyboardShouldPersistTaps='always'
keyExtractor={this.keyExtractor}
style={[style.listView, isSearch ? [style.search, {height: listHeight}] : null]}
sections={sections}
renderItem={this.renderItem}
style={style.listView}
enableEmptySections={true}
dataSource={this.state.dataSource}
renderSectionHeader={this.renderSectionHeader}
ItemSeparatorComponent={AutocompleteDivider}
initialNumToRender={10}
renderRow={this.renderRow}
renderFooter={this.renderFooter}
pageSize={10}
initialListSize={10}
/>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
listView: {
return StyleSheet.create({
section: {
justifyContent: 'center',
paddingLeft: 8,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.2),
borderLeftWidth: 1,
borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRightWidth: 1,
borderRightColor: changeOpacity(theme.centerChannelColor, 0.2)
},
sectionText: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.7),
paddingVertical: 7
},
sectionWrapper: {
backgroundColor: theme.centerChannelBg
},
search: {
minHeight: 125
listView: {
flex: 1,
backgroundColor: theme.centerChannelBg
},
loading: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
backgroundColor: theme.centerChannelBg,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderBottomWidth: 0
},
row: {
paddingVertical: 8,
flexDirection: 'row',
alignItems: 'center',
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)
},
rowIcon: {
color: changeOpacity(theme.centerChannelColor, 0.7),
fontSize: 14
},
rowPicture: {
marginHorizontal: 8,
width: 20,
alignItems: 'center',
justifyContent: 'center'
},
rowUsername: {
fontSize: 13,
color: theme.centerChannelColor
},
rowFullname: {
color: theme.centerChannelColor,
opacity: 0.6
},
textWrapper: {
flex: 1,
flexWrap: 'wrap',
paddingRight: 8
}
};
});
});

View File

@@ -4,45 +4,40 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {getTheme} from 'app/selectors/preferences';
import {autocompleteUsers} from 'mattermost-redux/actions/users';
import {getCurrentChannelId, getDefaultChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {
filterMembersInChannel,
filterMembersNotInChannel,
filterMembersInCurrentTeam,
getMatchTermForAtMention
} from 'app/selectors/autocomplete';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getDefaultChannel} from 'mattermost-redux/selectors/entities/channels';
import {getProfilesInCurrentChannel, getProfilesNotInCurrentChannel} from 'mattermost-redux/selectors/entities/users';
import AtMention from './at_mention';
function mapStateToProps(state, ownProps) {
const {cursorPosition, isSearch} = ownProps;
const currentChannelId = getCurrentChannelId(state);
const {currentChannelId} = state.entities.channels;
const value = ownProps.value.substring(0, cursorPosition);
const matchTerm = getMatchTermForAtMention(value, isSearch);
let teamMembers;
let inChannel;
let outChannel;
if (isSearch) {
teamMembers = filterMembersInCurrentTeam(state, matchTerm);
} else {
inChannel = filterMembersInChannel(state, matchTerm);
outChannel = filterMembersNotInChannel(state, matchTerm);
let postDraft;
if (ownProps.rootId.length) {
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 {
...ownProps,
currentUserId: state.entities.users.currentUserId,
currentChannelId,
currentTeamId: getCurrentTeamId(state),
currentTeamId: state.entities.teams.currentTeamId,
defaultChannel: getDefaultChannel(state),
matchTerm,
teamMembers,
inChannel,
outChannel,
postDraft,
autocompleteUsersInCurrentChannel: {
inChannel: getProfilesInCurrentChannel(state),
outChannel: getProfilesNotInCurrentChannel(state)
},
requestStatus: state.requests.users.autocompleteUsers.status,
theme: getTheme(state)
};

View File

@@ -1,87 +0,0 @@
// 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 {
Text,
TouchableOpacity,
View
} from 'react-native';
import ProfilePicture from 'app/components/profile_picture';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
export default class AtMentionItem extends PureComponent {
static propTypes = {
firstName: PropTypes.string,
lastName: PropTypes.string,
onPress: PropTypes.func.isRequired,
userId: PropTypes.string.isRequired,
username: PropTypes.string,
theme: PropTypes.object.isRequired
};
completeMention = () => {
const {onPress, username} = this.props;
onPress(username);
};
render() {
const {
firstName,
lastName,
userId,
username,
theme
} = this.props;
const style = getStyleFromTheme(theme);
const hasFullName = firstName.length > 0 && lastName.length > 0;
return (
<TouchableOpacity
key={userId}
onPress={this.completeMention}
style={style.row}
>
<View style={style.rowPicture}>
<ProfilePicture
userId={userId}
theme={theme}
size={20}
status={null}
/>
</View>
<Text style={style.rowUsername}>{`@${username}`}</Text>
{hasFullName && <Text style={style.rowUsername}>{' - '}</Text>}
{hasFullName && <Text style={style.rowFullname}>{`${firstName} ${lastName}`}</Text>}
</TouchableOpacity>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
row: {
paddingVertical: 8,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.centerChannelBg
},
rowPicture: {
marginHorizontal: 8,
width: 20,
alignItems: 'center',
justifyContent: 'center'
},
rowUsername: {
fontSize: 13,
color: theme.centerChannelColor
},
rowFullname: {
color: theme.centerChannelColor,
opacity: 0.6
}
};
});

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import AtMentionItem from './at_mention_item';
function mapStateToProps(state, ownProps) {
const user = getUser(state, ownProps.userId);
return {
firstName: user.first_name,
lastName: user.last_name,
username: user.username,
theme: getTheme(state)
};
}
export default connect(mapStateToProps)(AtMentionItem);

View File

@@ -1,180 +0,0 @@
// 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 {
Keyboard,
Platform,
View
} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import AtMention from './at_mention';
import ChannelMention from './channel_mention';
import EmojiSuggestion from './emoji_suggestion';
import SlashSuggestion from './slash_suggestion';
export default class Autocomplete extends PureComponent {
static propTypes = {
deviceHeight: PropTypes.number,
onChangeText: PropTypes.func.isRequired,
rootId: PropTypes.string,
isSearch: PropTypes.bool,
theme: PropTypes.object.isRequired,
value: PropTypes.string
};
static defaultProps = {
isSearch: false
};
state = {
cursorPosition: 0,
atMentionCount: 0,
channelMentionCount: 0,
emojiCount: 0,
commandCount: 0,
keyboardOffset: 0
};
handleSelectionChange = (event) => {
this.setState({
cursorPosition: event.nativeEvent.selection.end
});
};
handleAtMentionCountChange = (atMentionCount) => {
this.setState({atMentionCount});
};
handleChannelMentionCountChange = (channelMentionCount) => {
this.setState({channelMentionCount});
};
handleEmojiCountChange = (emojiCount) => {
this.setState({emojiCount});
};
handleCommandCountChange = (commandCount) => {
this.setState({commandCount});
};
componentWillMount() {
this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this.keyboardDidShow);
this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this.keyboardDidHide);
}
componentWillUnmount() {
this.keyboardDidShowListener.remove();
this.keyboardDidHideListener.remove();
}
keyboardDidShow = (e) => {
const {height} = e.endCoordinates;
this.setState({keyboardOffset: height});
}
keyboardDidHide = () => {
this.setState({keyboardOffset: 0});
}
listHeight() {
let offset = Platform.select({ios: 65, android: 75});
if (DeviceInfo.getModel() === 'iPhone X') {
offset = 90;
}
return this.props.deviceHeight - offset - this.state.keyboardOffset;
}
render() {
const style = getStyleFromTheme(this.props.theme);
const wrapperStyle = [];
const containerStyle = [];
if (this.props.isSearch) {
wrapperStyle.push(style.base, style.searchContainer);
containerStyle.push(style.content);
} else {
containerStyle.push(style.base, style.container);
}
// We always need to render something, but we only draw the borders when we have results to show
const {atMentionCount, channelMentionCount, emojiCount, commandCount} = this.state;
if (atMentionCount + channelMentionCount + emojiCount + commandCount > 0) {
if (this.props.isSearch) {
wrapperStyle.push(style.bordersSearch);
} else {
containerStyle.push(style.borders);
}
}
const listHeight = this.listHeight();
return (
<View style={wrapperStyle}>
<View style={containerStyle}>
<AtMention
listHeight={listHeight}
cursorPosition={this.state.cursorPosition}
onResultCountChange={this.handleAtMentionCountChange}
{...this.props}
/>
<ChannelMention
listHeight={listHeight}
cursorPosition={this.state.cursorPosition}
onResultCountChange={this.handleChannelMentionCountChange}
{...this.props}
/>
<EmojiSuggestion
cursorPosition={this.state.cursorPosition}
onResultCountChange={this.handleEmojiCountChange}
{...this.props}
/>
<SlashSuggestion
onResultCountChange={this.handleCommandCountChange}
{...this.props}
/>
</View>
</View>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
base: {
left: 0,
overflow: 'hidden',
position: 'absolute',
right: 0
},
borders: {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderBottomWidth: 0
},
bordersSearch: {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2)
},
container: {
bottom: 0,
maxHeight: 200
},
content: {
flex: 1
},
searchContainer: {
flex: 1,
...Platform.select({
android: {
top: 46
},
ios: {
top: 44
}
})
}
};
});

View File

@@ -1,32 +0,0 @@
// 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 {View} from 'react-native';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
export default class AutocompleteDivider extends PureComponent {
static propTypes = {
theme: PropTypes.object.isRequired
};
render() {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
return (
<View style={style.divider}/>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
divider: {
height: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2)
}
};
});

View File

@@ -1,16 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import AutocompleteDivider from './autocomplete_divider';
function mapStateToProps(state) {
return {
theme: getTheme(state)
};
}
export default connect(mapStateToProps)(AutocompleteDivider);

View File

@@ -1,54 +0,0 @@
// 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 {View} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
export default class AutocompleteSectionHeader extends PureComponent {
static propTypes = {
defaultMessage: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired
};
render() {
const {defaultMessage, id, theme} = this.props;
const style = getStyleFromTheme(theme);
return (
<View style={style.sectionWrapper}>
<View style={style.section}>
<FormattedText
id={id}
defaultMessage={defaultMessage}
style={style.sectionText}
/>
</View>
</View>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
section: {
justifyContent: 'center',
paddingLeft: 8,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.2)
},
sectionText: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.7),
paddingVertical: 7
},
sectionWrapper: {
backgroundColor: theme.centerChannelBg
}
};
});

View File

@@ -1,205 +1,275 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {SectionList} from 'react-native';
import {
ListView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import {RequestStatus} from 'mattermost-redux/constants';
import {CHANNEL_MENTION_REGEX, CHANNEL_MENTION_SEARCH_REGEX} from 'app/constants/autocomplete';
import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divider';
import AutocompleteSectionHeader from 'app/components/autocomplete/autocomplete_section_header';
import ChannelMentionItem from 'app/components/autocomplete/channel_mention_item';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i;
export default class ChannelMention extends PureComponent {
export default class ChannelMention extends Component {
static propTypes = {
actions: PropTypes.shape({
searchChannels: PropTypes.func.isRequired
}).isRequired,
currentChannelId: PropTypes.string.isRequired,
currentTeamId: PropTypes.string.isRequired,
cursorPosition: PropTypes.number.isRequired,
isSearch: PropTypes.bool,
listHeight: PropTypes.number,
matchTerm: PropTypes.string,
myChannels: PropTypes.array,
otherChannels: PropTypes.array,
onChangeText: PropTypes.func.isRequired,
onResultCountChange: PropTypes.func.isRequired,
privateChannels: PropTypes.array,
publicChannels: PropTypes.array,
autocompleteChannels: PropTypes.object.isRequired,
postDraft: PropTypes.string,
requestStatus: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
value: PropTypes.string
onChangeText: PropTypes.func.isRequired,
actions: PropTypes.shape({
searchChannels: PropTypes.func.isRequired
})
};
static defaultProps = {
isSearch: false,
value: ''
postDraft: ''
};
constructor(props) {
super(props);
const ds = new ListView.DataSource({
sectionHeaderHasChanged: (s1, s2) => s1 !== s2,
rowHasChanged: (r1, r2) => r1 !== r2
});
this.state = {
sections: []
active: false,
dataSource: ds.cloneWithRowsAndSections(props.autocompleteChannels)
};
}
componentWillReceiveProps(nextProps) {
const {isSearch, matchTerm, myChannels, otherChannels, privateChannels, publicChannels, requestStatus} = nextProps;
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(CHANNEL_MENTION_REGEX);
if ((matchTerm !== this.props.matchTerm && matchTerm === null) || this.state.mentionComplete) {
// if the term changes but is null or the mention has been completed we render this component as null
this.setState({
mentionComplete: false,
sections: []
});
// If not match or if user clicked on a channel
if (!match || this.state.mentionComplete) {
const nextState = {
active: false,
mentionComplete: false
};
this.props.onResultCountChange(0);
// Handle the case where the user typed a ~ first and then backspaced
if (nextProps.postDraft.length < this.props.postDraft.length) {
nextState.matchTerm = null;
}
return;
} else if (matchTerm === null) {
// if the terms did not change but is null then we don't need to do anything
this.setState(nextState);
return;
}
if (matchTerm !== this.props.matchTerm) {
// if the term changed and we haven't made the request do that first
const matchTerm = match[2];
const myChannels = this.filter(nextProps.autocompleteChannels.myChannels, matchTerm);
const otherChannels = this.filter(nextProps.autocompleteChannels.otherChannels, matchTerm);
// Show loading indicator on first pull for channels
if (nextProps.requestStatus === RequestStatus.STARTED && ((myChannels.length === 0 && otherChannels.length === 0) || matchTerm === '')) {
this.setState({
active: true,
loading: true
});
return;
}
// Still matching the same term that didn't return any results
if (match[0].startsWith(`~${this.state.matchTerm}`) && (myChannels.length === 0 && otherChannels.length === 0)) {
this.setState({
active: false
});
return;
}
if (matchTerm !== this.state.matchTerm) {
this.setState({
matchTerm
});
const {currentTeamId} = this.props;
this.props.actions.searchChannels(currentTeamId, matchTerm);
return;
}
if (requestStatus !== RequestStatus.STARTED &&
(myChannels !== this.props.myChannels || otherChannels !== this.props.otherChannels ||
privateChannels !== this.props.privateChannels || publicChannels !== this.props.publicChannels)) {
// if the request is complete and the term is not null we show the autocomplete
const sections = [];
if (isSearch) {
if (publicChannels.length) {
sections.push({
id: 'suggestion.search.public',
defaultMessage: 'Public Channels',
data: publicChannels,
key: 'publicChannels'
});
}
if (privateChannels.length) {
sections.push({
id: 'suggestion.search.private',
defaultMessage: 'Private Channels',
data: privateChannels,
key: 'privateChannels'
});
}
} else {
if (myChannels.length) {
sections.push({
id: 'suggestion.mention.channels',
defaultMessage: 'My Channels',
data: myChannels,
key: 'myChannels'
});
}
if (otherChannels.length) {
sections.push({
id: 'suggestion.mention.morechannels',
defaultMessage: 'Other Channels',
data: otherChannels,
key: 'otherChannels'
});
}
if (nextProps.requestStatus !== RequestStatus.STARTED && this.props.autocompleteChannels !== nextProps.autocompleteChannels) {
let data = {};
if (myChannels.length > 0) {
data = Object.assign({}, data, {myChannels});
}
if (otherChannels.length > 0) {
data = Object.assign({}, data, {otherChannels});
}
this.setState({
sections
active: true,
loading: false,
dataSource: this.state.dataSource.cloneWithRowsAndSections(data)
});
this.props.onResultCountChange(sections.reduce((total, section) => total + section.data.length, 0));
}
}
filter = (channels, matchTerm) => {
return channels.filter((c) => c.name.includes(matchTerm) || c.display_name.includes(matchTerm));
};
completeMention = (mention) => {
const {cursorPosition, isSearch, onChangeText, value} = this.props;
const mentionPart = value.substring(0, cursorPosition);
const mentionPart = this.props.postDraft.substring(0, this.props.cursorPosition);
let completedDraft;
if (isSearch) {
const channelOrIn = mentionPart.includes('in:') ? 'in:' : 'channel:';
completedDraft = mentionPart.replace(CHANNEL_MENTION_SEARCH_REGEX, `${channelOrIn} ${mention} `);
} else {
completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `);
let completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `);
if (this.props.postDraft.length > this.props.cursorPosition) {
completedDraft += this.props.postDraft.substring(this.props.cursorPosition);
}
if (value.length > cursorPosition) {
completedDraft += value.substring(cursorPosition);
}
onChangeText(completedDraft, true);
this.setState({mentionComplete: true});
this.props.onChangeText(completedDraft);
this.setState({
active: false,
mentionComplete: true,
matchTerm: `${mention} `
});
};
keyExtractor = (item) => {
return item.id || item;
};
renderSectionHeader = (sectionData, sectionId) => {
const style = getStyleFromTheme(this.props.theme);
const localization = {
myChannels: {
id: 'suggestion.mention.channels',
defaultMessage: 'My Channels'
},
otherChannels: {
id: 'suggestion.mention.morechannels',
defaultMessage: 'Other Channels'
}
};
renderSectionHeader = ({section}) => {
return (
<AutocompleteSectionHeader
id={section.id}
defaultMessage={section.defaultMessage}
theme={this.props.theme}
/>
<View style={style.sectionWrapper}>
<View style={style.section}>
<FormattedText
id={localization[sectionId].id}
defaultMessage={localization[sectionId].defaultMessage}
style={style.sectionText}
/>
</View>
</View>
);
};
renderItem = ({item}) => {
renderRow = (data) => {
const style = getStyleFromTheme(this.props.theme);
return (
<ChannelMentionItem
channelId={item}
onPress={this.completeMention}
/>
<TouchableOpacity
onPress={() => this.completeMention(data.name)}
style={style.row}
>
<Text style={style.rowDisplayName}>{data.display_name}</Text>
<Text style={style.rowName}>{` (~${data.name})`}</Text>
</TouchableOpacity>
);
};
render() {
const {isSearch, listHeight, theme} = this.props;
const {mentionComplete, sections} = this.state;
if (sections.length === 0 || mentionComplete) {
// If we are not in an active state or the mention has been completed return null so nothing is rendered
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 {requestStatus, theme} = this.props;
const style = getStyleFromTheme(theme);
if (this.state.loading && requestStatus === RequestStatus.STARTED) {
return (
<View style={style.loading}>
<FormattedText
id='analytics.chart.loading'
defaultMessage='Loading...'
style={style.sectionText}
/>
</View>
);
}
return (
<SectionList
<ListView
keyboardShouldPersistTaps='always'
keyExtractor={this.keyExtractor}
style={[style.listView, isSearch ? [style.search, {height: listHeight}] : null]}
sections={sections}
renderItem={this.renderItem}
style={style.listView}
enableEmptySections={true}
dataSource={this.state.dataSource}
renderSectionHeader={this.renderSectionHeader}
ItemSeparatorComponent={AutocompleteDivider}
initialNumToRender={10}
renderRow={this.renderRow}
pageSize={10}
initialListSize={10}
/>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
listView: {
return StyleSheet.create({
section: {
justifyContent: 'center',
paddingLeft: 8,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.2),
borderLeftWidth: 1,
borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRightWidth: 1,
borderRightColor: changeOpacity(theme.centerChannelColor, 0.2)
},
sectionText: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.7),
paddingVertical: 7
},
sectionWrapper: {
backgroundColor: theme.centerChannelBg
},
search: {
minHeight: 125
listView: {
flex: 1,
backgroundColor: theme.centerChannelBg
},
loading: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
backgroundColor: theme.centerChannelBg,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderBottomWidth: 0
},
row: {
padding: 8,
flexDirection: 'row',
alignItems: 'center',
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)
},
rowDisplayName: {
fontSize: 13,
color: theme.centerChannelColor
},
rowName: {
color: theme.centerChannelColor,
opacity: 0.6
}
};
});
});

View File

@@ -5,44 +5,40 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {searchChannels} from 'mattermost-redux/actions/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {General} from 'mattermost-redux/constants';
import {getMyChannels, getOtherChannels} from 'mattermost-redux/selectors/entities/channels';
import {
filterMyChannels,
filterOtherChannels,
filterPublicChannels,
filterPrivateChannels,
getMatchTermForChannelMention
} from 'app/selectors/autocomplete';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getTheme} from 'app/selectors/preferences';
import ChannelMention from './channel_mention';
function mapStateToProps(state, ownProps) {
const {cursorPosition, isSearch} = ownProps;
const {currentChannelId} = state.entities.channels;
const value = ownProps.value.substring(0, cursorPosition);
const matchTerm = getMatchTermForChannelMention(value, isSearch);
let myChannels;
let otherChannels;
let publicChannels;
let privateChannels;
if (isSearch) {
publicChannels = filterPublicChannels(state, matchTerm);
privateChannels = filterPrivateChannels(state, matchTerm);
} else {
myChannels = filterMyChannels(state, matchTerm);
otherChannels = filterOtherChannels(state, matchTerm);
let postDraft;
if (ownProps.rootId.length) {
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;
}
}
const autocompleteChannels = {
myChannels: getMyChannels(state).filter((c) => c.type !== General.DM_CHANNEL && c.type !== General.GM_CHANNEL),
otherChannels: getOtherChannels(state)
};
return {
myChannels,
otherChannels,
publicChannels,
privateChannels,
currentTeamId: getCurrentTeamId(state),
matchTerm,
...ownProps,
currentChannelId,
currentTeamId: state.entities.teams.currentTeamId,
postDraft,
autocompleteChannels,
requestStatus: state.requests.channels.getChannels.status,
theme: getTheme(state)
};

View File

@@ -1,67 +0,0 @@
// 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 {
Text,
TouchableOpacity
} from 'react-native';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
export default class ChannelMentionItem extends PureComponent {
static propTypes = {
channelId: PropTypes.string.isRequired,
displayName: PropTypes.string,
name: PropTypes.string,
onPress: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired
};
completeMention = () => {
const {onPress, name} = this.props;
onPress(name);
};
render() {
const {
channelId,
displayName,
name,
theme
} = this.props;
const style = getStyleFromTheme(theme);
return (
<TouchableOpacity
key={channelId}
onPress={this.completeMention}
style={style.row}
>
<Text style={style.rowDisplayName}>{displayName}</Text>
<Text style={style.rowName}>{` (~${name})`}</Text>
</TouchableOpacity>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
row: {
padding: 8,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.centerChannelBg
},
rowDisplayName: {
fontSize: 13,
color: theme.centerChannelColor
},
rowName: {
color: theme.centerChannelColor,
opacity: 0.6
}
};
});

View File

@@ -1,22 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import ChannelMentionItem from './channel_mention_item';
function mapStateToProps(state, ownProps) {
const channel = getChannel(state, ownProps.channelId);
return {
displayName: channel.display_name,
name: channel.name,
theme: getTheme(state)
};
}
export default connect(mapStateToProps)(ChannelMentionItem);

View File

@@ -1,191 +0,0 @@
// 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,
Text,
TouchableOpacity,
View
} from 'react-native';
import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divider';
import Emoji from 'app/components/emoji';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
const EMOJI_REGEX = /(^|\s|^\+|^-)(:([^:\s]*))$/i;
const EMOJI_REGEX_WITHOUT_PREFIX = /\B(:([^:\s]*))$/i;
export default class EmojiSuggestion extends Component {
static propTypes = {
actions: PropTypes.shape({
addReactionToLatestPost: PropTypes.func.isRequired
}).isRequired,
cursorPosition: PropTypes.number,
emojis: PropTypes.array.isRequired,
isSearch: PropTypes.bool,
theme: PropTypes.object.isRequired,
onChangeText: PropTypes.func.isRequired,
onResultCountChange: PropTypes.func.isRequired,
rootId: PropTypes.string,
value: PropTypes.string
};
static defaultProps = {
defaultChannel: {},
value: ''
};
state = {
active: false,
dataSource: []
};
componentWillReceiveProps(nextProps) {
if (nextProps.isSearch) {
return;
}
const regex = EMOJI_REGEX;
const match = nextProps.value.substring(0, nextProps.cursorPosition).match(regex);
if (!match || this.state.emojiComplete) {
this.setState({
active: false,
matchTerm: null,
emojiComplete: false
});
this.props.onResultCountChange(0);
return;
}
const matchTerm = match[3];
if (matchTerm !== this.state.matchTerm) {
this.setState({
matchTerm
});
}
let data = [];
if (matchTerm.length) {
const lowerCaseMatchTerm = matchTerm.toLowerCase();
const startsWith = [];
const includes = [];
nextProps.emojis.forEach((emoji) => {
if (emoji.startsWith(lowerCaseMatchTerm)) {
startsWith.push(emoji);
} else {
includes.push(emoji);
}
});
data = [...startsWith.sort(), ...includes.sort()];
} else {
const initialEmojis = [...nextProps.emojis];
initialEmojis.splice(0, 300);
data = initialEmojis.sort();
}
this.setState({
active: data.length > 0,
dataSource: data
});
this.props.onResultCountChange(data.length);
}
completeSuggestion = (emoji) => {
const {actions, cursorPosition, onChangeText, value, rootId} = this.props;
const emojiPart = value.substring(0, cursorPosition);
if (emojiPart.startsWith('+:')) {
actions.addReactionToLatestPost(emoji, rootId);
onChangeText('');
} else {
let completedDraft = emojiPart.replace(EMOJI_REGEX_WITHOUT_PREFIX, `:${emoji}: `);
if (value.length > cursorPosition) {
completedDraft += value.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={20}
/>
</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}
ItemSeparatorComponent={AutocompleteDivider}
pageSize={10}
initialListSize={10}
/>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
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
}
};
});

View File

@@ -1,45 +0,0 @@
// 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 {bindActionCreators} from 'redux';
import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
import {addReactionToLatestPost} from 'app/actions/views/emoji';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {EmojiIndicesByAlias} from 'app/utils/emojis';
import EmojiSuggestion from './emoji_suggestion';
const getEmojisByName = createSelector(
getCustomEmojisByName,
(customEmojis) => {
const emoticons = [];
for (const [key] of [...EmojiIndicesByAlias.entries(), ...customEmojis.entries()]) {
emoticons.push(key);
}
return emoticons;
}
);
function mapStateToProps(state) {
const emojis = getEmojisByName(state);
return {
emojis,
theme: getTheme(state)
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
addReactionToLatestPost
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(EmojiSuggestion);

View File

@@ -1,20 +1,59 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
View
} from 'react-native';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import AtMention from './at_mention';
import ChannelMention from './channel_mention';
import {getDimensions} from 'app/selectors/device';
const style = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
maxHeight: 200,
overflow: 'hidden'
}
});
import Autocomplete from './autocomplete';
function mapStateToProps(state) {
const {deviceHeight} = getDimensions(state);
return {
deviceHeight,
theme: getTheme(state)
export default class Autocomplete extends Component {
static propTypes = {
onChangeText: PropTypes.func.isRequired,
rootId: PropTypes.string
};
}
export default connect(mapStateToProps, null, null, {withRef: true})(Autocomplete);
state = {
cursorPosition: 0
};
handleSelectionChange = (event) => {
this.setState({
cursorPosition: event.nativeEvent.selection.end
});
};
render() {
return (
<View>
<View style={style.container}>
<AtMention
cursorPosition={this.state.cursorPosition}
onChangeText={this.props.onChangeText}
rootId={this.props.rootId}
/>
<ChannelMention
cursorPosition={this.state.cursorPosition}
onChangeText={this.props.onChangeText}
rootId={this.props.rootId}
/>
</View>
</View>
);
}
}

View File

@@ -1,45 +0,0 @@
// 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 {createSelector} from 'reselect';
import {getAutocompleteCommands} from 'mattermost-redux/actions/integrations';
import {getAutocompleteCommandsList} from 'mattermost-redux/selectors/entities/integrations';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import SlashSuggestion from './slash_suggestion';
// TODO: Remove when all below commands have been implemented
const COMMANDS_TO_IMPLEMENT_LATER = ['collapse', 'expand', 'join', 'open', 'leave', 'logout', 'msg', 'grpmsg'];
const NON_MOBILE_COMMANDS = ['rename', 'invite_people', 'shortcuts', 'search', 'help', 'settings', 'remove'];
const COMMANDS_TO_HIDE_ON_MOBILE = [...COMMANDS_TO_IMPLEMENT_LATER, ...NON_MOBILE_COMMANDS];
const mobileCommandsSelector = createSelector(
getAutocompleteCommandsList,
(commands) => {
return commands.filter((command) => !COMMANDS_TO_HIDE_ON_MOBILE.includes(command.trigger));
}
);
function mapStateToProps(state) {
return {
commands: mobileCommandsSelector(state),
commandsRequest: state.requests.integrations.getAutocompleteCommands,
currentTeamId: getCurrentTeamId(state),
theme: getTheme(state)
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
getAutocompleteCommands
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(SlashSuggestion);

View File

@@ -1,167 +0,0 @@
// 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
} from 'react-native';
import {RequestStatus} from 'mattermost-redux/constants';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import SlashSuggestionItem from './slash_suggestion_item';
const SLASH_REGEX = /(^\/)([a-zA-Z-]*)$/;
const TIME_BEFORE_NEXT_COMMAND_REQUEST = 1000 * 60 * 5;
export default class SlashSuggestion extends Component {
static propTypes = {
actions: PropTypes.shape({
getAutocompleteCommands: PropTypes.func.isRequired
}).isRequired,
currentTeamId: PropTypes.string.isRequired,
commands: PropTypes.array,
commandsRequest: PropTypes.object.isRequired,
isSearch: PropTypes.bool,
theme: PropTypes.object.isRequired,
onChangeText: PropTypes.func.isRequired,
onResultCountChange: PropTypes.func.isRequired,
value: PropTypes.string
};
static defaultProps = {
defaultChannel: {},
value: ''
};
state = {
active: false,
suggestionComplete: false,
dataSource: [],
lastCommandRequest: 0
};
componentWillReceiveProps(nextProps) {
if (nextProps.isSearch) {
return;
}
const {currentTeamId} = this.props;
const {
commands: nextCommands,
commandsRequest: nextCommandsRequest,
currentTeamId: nextTeamId,
value: nextValue
} = nextProps;
if (currentTeamId !== nextTeamId) {
this.setState({
lastCommandRequest: 0
});
}
const match = nextValue.match(SLASH_REGEX);
if (!match || this.state.suggestionComplete) {
this.setState({
active: false,
matchTerm: null,
suggestionComplete: false
});
this.props.onResultCountChange(0);
return;
}
const dataIsStale = Date.now() - this.state.lastCommandRequest > TIME_BEFORE_NEXT_COMMAND_REQUEST;
if ((!nextCommands.length || dataIsStale) && nextCommandsRequest.status !== RequestStatus.STARTED) {
this.props.actions.getAutocompleteCommands(nextProps.currentTeamId);
this.setState({
lastCommandRequest: Date.now()
});
}
const matchTerm = match[2];
const data = this.filterSlashSuggestions(matchTerm, nextCommands);
this.setState({
active: data.length,
dataSource: data
});
this.props.onResultCountChange(data.length);
}
filterSlashSuggestions = (matchTerm, commands) => {
return commands.filter((command) => {
if (!command.auto_complete) {
return false;
} else if (!matchTerm) {
return true;
}
return command.display_name.startsWith(matchTerm) || command.trigger.startsWith(matchTerm);
});
}
completeSuggestion = (command) => {
const {onChangeText} = this.props;
const completedDraft = `/${command} `;
onChangeText(completedDraft);
this.setState({
active: false,
suggestionComplete: true
});
};
keyExtractor = (item) => item.id || item.trigger;
renderItem = ({item}) => (
<SlashSuggestionItem
displayName={item.display_name}
description={item.auto_complete_desc}
hint={item.auto_complete_hint}
onPress={this.completeSuggestion}
theme={this.props.theme}
trigger={item.trigger}
/>
)
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 {
listView: {
flex: 1,
backgroundColor: theme.centerChannelBg
}
};
});

View File

@@ -1,83 +0,0 @@
// 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 {
Text,
TouchableOpacity
} from 'react-native';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
export default class SlashSuggestionItem extends PureComponent {
static propTypes = {
displayName: PropTypes.string,
description: PropTypes.string,
hint: PropTypes.string,
onPress: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
trigger: PropTypes.string
};
completeSuggestion = () => {
const {onPress, trigger} = this.props;
onPress(trigger);
};
render() {
const {
displayName,
description,
hint,
theme,
trigger
} = this.props;
const style = getStyleFromTheme(theme);
return (
<TouchableOpacity
onPress={this.completeSuggestion}
style={style.row}
>
<Text style={style.suggestionName}>{`/${displayName || trigger} ${hint}`}</Text>
<Text style={style.suggestionDescription}>{description}</Text>
</TouchableOpacity>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
row: {
height: 55,
justifyContent: '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)
},
rowDisplayName: {
fontSize: 13,
color: theme.centerChannelColor
},
rowName: {
color: theme.centerChannelColor,
opacity: 0.6
},
suggestionDescription: {
fontSize: 11,
color: changeOpacity(theme.centerChannelColor, 0.6)
},
suggestionName: {
fontSize: 13,
color: theme.centerChannelColor,
marginBottom: 5
}
};
});

View File

@@ -1,100 +0,0 @@
// 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 {
Text,
TouchableOpacity,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import FormattedText from 'app/components/formatted_text';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
export default class SpecialMentionItem extends PureComponent {
static propTypes = {
completeHandle: PropTypes.string.isRequired,
defaultMessage: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
values: PropTypes.object
};
completeMention = () => {
const {onPress, completeHandle} = this.props;
onPress(completeHandle);
};
render() {
const {
defaultMessage,
id,
completeHandle,
theme,
values
} = this.props;
const style = getStyleFromTheme(theme);
return (
<TouchableOpacity
onPress={this.completeMention}
style={style.row}
>
<View style={style.rowPicture}>
<Icon
name='users'
style={style.rowIcon}
/>
</View>
<Text style={style.textWrapper}>
<Text style={style.rowUsername}>{`@${completeHandle}`}</Text>
<Text style={style.rowUsername}>{' - '}</Text>
<FormattedText
id={id}
defaultMessage={defaultMessage}
values={values}
style={style.rowFullname}
/>
</Text>
</TouchableOpacity>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
row: {
paddingVertical: 8,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.centerChannelBg
},
rowPicture: {
marginHorizontal: 8,
width: 20,
alignItems: 'center',
justifyContent: 'center'
},
rowIcon: {
color: changeOpacity(theme.centerChannelColor, 0.7),
fontSize: 14
},
rowUsername: {
fontSize: 13,
color: theme.centerChannelColor
},
rowFullname: {
color: theme.centerChannelColor,
flex: 1,
opacity: 0.6
},
textWrapper: {
flex: 1,
flexWrap: 'wrap',
paddingRight: 8
}
};
});

View File

@@ -32,6 +32,7 @@ export default class Badge extends PureComponent {
constructor(props) {
super(props);
this.width = 0;
this.mounted = false;
}
@@ -66,8 +67,6 @@ export default class Badge extends PureComponent {
};
onLayout = (e) => {
const height = Math.max(e.nativeEvent.layout.height, this.props.minHeight);
const borderRadius = height / 2;
let width;
if (e.nativeEvent.layout.width <= e.nativeEvent.layout.height) {
@@ -76,14 +75,26 @@ export default class Badge extends PureComponent {
width = e.nativeEvent.layout.width + this.props.extraPaddingHorizontal;
}
width = Math.max(width, this.props.minWidth);
if (this.width === width) {
return;
}
this.width = width;
const height = Math.max(e.nativeEvent.layout.height, this.props.minHeight);
const borderRadius = height / 2;
this.setNativeProps({
style: {
width,
height,
borderRadius,
opacity: 1
borderRadius
}
});
setTimeout(() => {
this.setNativeProps({
style: {
display: 'flex'
}
});
}, 100);
};
renderText = () => {
@@ -107,10 +118,6 @@ export default class Badge extends PureComponent {
};
render() {
if (!this.props.count) {
return null;
}
return (
<TouchableWithoutFeedback
{...this.panResponder.panHandlers}
@@ -118,7 +125,7 @@ export default class Badge extends PureComponent {
>
<View
ref='badgeContainer'
style={[styles.badge, this.props.style, {opacity: 0}]}
style={[styles.badge, this.props.style, {display: 'none'}]}
>
<View style={styles.wrapper}>
{this.renderText()}

View File

@@ -1,38 +1,33 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {Component} from 'react';
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
BackHandler,
InteractionManager,
Keyboard,
Platform,
StyleSheet,
View
} from 'react-native';
import SafeAreaView from 'app/components/safe_area_view';
import Drawer from 'app/components/drawer';
import {alertErrorWithFallback} from 'app/utils/general';
import tracker from 'app/utils/time_tracker';
import ChannelsList from './channels_list';
import DrawerSwiper from './drawer_swipper';
import Swiper from './swiper';
import TeamsList from './teams_list';
import {General, WebsocketEvents} from 'mattermost-redux/constants';
import {General} from 'mattermost-redux/constants';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
const DRAWER_INITIAL_OFFSET = 40;
const DRAWER_LANDSCAPE_OFFSET = 150;
export default class ChannelDrawer extends Component {
export default class ChannelDrawer extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
getTeams: PropTypes.func.isRequired,
handleSelectChannel: PropTypes.func.isRequired,
markChannelAsViewed: PropTypes.func.isRequired,
viewChannel: PropTypes.func.isRequired,
makeDirectChannel: PropTypes.func.isRequired,
markChannelAsRead: PropTypes.func.isRequired,
setChannelDisplayName: PropTypes.func.isRequired,
@@ -40,32 +35,30 @@ export default class ChannelDrawer extends Component {
}).isRequired,
blurPostTextBox: PropTypes.func.isRequired,
children: PropTypes.node,
currentTeamId: PropTypes.string.isRequired,
channels: PropTypes.object,
currentChannel: PropTypes.object,
currentDisplayName: PropTypes.string,
channelMembers: PropTypes.object,
currentTeam: PropTypes.object,
currentUserId: PropTypes.string.isRequired,
isLandscape: PropTypes.bool.isRequired,
isTablet: PropTypes.bool.isRequired,
intl: PropTypes.object.isRequired,
myTeamMembers: PropTypes.object.isRequired,
navigator: PropTypes.object,
teamsCount: PropTypes.number.isRequired,
theme: PropTypes.object.isRequired
};
closeLeftHandle = null;
openLeftHandle = null;
static defaultProps = {
currentTeam: {},
currentChannel: {}
};
state = {
openDrawer: false,
openDrawerOffset: DRAWER_INITIAL_OFFSET
};
swiperIndex = 1;
constructor(props) {
super(props);
let openDrawerOffset = DRAWER_INITIAL_OFFSET;
if (props.isLandscape || props.isTablet) {
openDrawerOffset = DRAWER_LANDSCAPE_OFFSET;
}
this.state = {
openDrawerOffset
};
}
componentWillMount() {
this.props.actions.getTeams();
}
@@ -73,46 +66,18 @@ export default class ChannelDrawer extends Component {
componentDidMount() {
EventEmitter.on('open_channel_drawer', this.openChannelDrawer);
EventEmitter.on('close_channel_drawer', this.closeChannelDrawer);
EventEmitter.on(WebsocketEvents.CHANNEL_UPDATED, this.handleUpdateTitle);
BackHandler.addEventListener('hardwareBackPress', this.handleAndroidBack);
}
componentWillReceiveProps(nextProps) {
const {isLandscape} = this.props;
if (nextProps.isLandscape !== isLandscape) {
if (this.state.openDrawerOffset !== 0) {
let openDrawerOffset = DRAWER_INITIAL_OFFSET;
if (nextProps.isLandscape || this.props.isTablet) {
openDrawerOffset = DRAWER_LANDSCAPE_OFFSET;
}
this.setState({openDrawerOffset});
}
}
}
shouldComponentUpdate(nextProps, nextState) {
const {currentTeamId, isLandscape, teamsCount} = this.props;
const {openDrawerOffset} = this.state;
if (nextState.openDrawerOffset !== openDrawerOffset) {
return true;
}
return nextProps.currentTeamId !== currentTeamId ||
nextProps.isLandscape !== isLandscape ||
nextProps.teamsCount !== teamsCount;
}
componentWillUnmount() {
EventEmitter.off('open_channel_drawer', this.openChannelDrawer);
EventEmitter.off('close_channel_drawer', this.closeChannelDrawer);
EventEmitter.off(WebsocketEvents.CHANNEL_UPDATED, this.handleUpdateTitle);
BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack);
}
handleAndroidBack = () => {
if (this.refs.drawer && this.refs.drawer.isOpened()) {
this.refs.drawer.close();
if (this.state.openDrawer) {
this.setState({openDrawer: false});
return true;
}
@@ -120,13 +85,7 @@ export default class ChannelDrawer extends Component {
};
closeChannelDrawer = () => {
if (this.refs.drawer && this.refs.drawer.isOpened()) {
this.refs.drawer.close();
}
};
drawerSwiperRef = (ref) => {
this.drawerSwiper = ref;
this.setState({openDrawer: false});
};
handleDrawerClose = () => {
@@ -145,7 +104,8 @@ export default class ChannelDrawer extends Component {
};
handleDrawerOpen = () => {
if (this.state.openDrawerOffset !== 0) {
this.setState({openDrawer: true});
if (this.state.openDrawerOffset === DRAWER_INITIAL_OFFSET) {
Keyboard.dismiss();
}
@@ -179,96 +139,89 @@ export default class ChannelDrawer extends Component {
};
};
handleUpdateTitle = (channel) => {
let channelName = '';
if (channel.display_name) {
channelName = channel.display_name;
}
this.props.actions.setChannelDisplayName(channelName);
};
openChannelDrawer = () => {
this.props.blurPostTextBox();
if (this.refs.drawer && !this.refs.drawer.isOpened()) {
this.refs.drawer.open();
}
this.setState({openDrawer: true});
};
selectChannel = (channel, currentChannelId) => {
selectChannel = (id) => {
const {
actions
actions,
currentChannel
} = this.props;
const {
handleSelectChannel,
markChannelAsRead,
setChannelLoading,
setChannelDisplayName,
markChannelAsViewed
viewChannel
} = actions;
tracker.channelSwitch = Date.now();
setChannelLoading(channel.id !== currentChannelId);
setChannelDisplayName(channel.display_name);
markChannelAsRead(id, currentChannel.id);
setChannelLoading();
viewChannel(id, currentChannel.id);
this.closeChannelDrawer();
InteractionManager.runAfterInteractions(() => {
handleSelectChannel(channel.id);
requestAnimationFrame(() => {
// mark the channel as viewed after all the frame has flushed
markChannelAsRead(channel.id, currentChannelId);
if (channel.id !== currentChannelId) {
markChannelAsViewed(currentChannelId);
}
});
handleSelectChannel(id);
});
};
joinChannel = async (channel, currentChannelId) => {
joinChannel = async (channel) => {
const {
actions,
currentTeamId,
currentChannel,
currentDisplayName,
currentTeam,
currentUserId,
intl
} = this.props;
const {
handleSelectChannel,
joinChannel,
makeDirectChannel
makeDirectChannel,
markChannelAsRead,
setChannelDisplayName,
setChannelLoading,
viewChannel
} = actions;
markChannelAsRead(currentChannel.id);
setChannelLoading();
viewChannel(currentChannel.id);
setChannelDisplayName(channel.display_name);
const displayValue = {displayName: channel.display_name};
let result;
if (channel.type === General.DM_CHANNEL) {
result = await makeDirectChannel(channel.id);
const result = await makeDirectChannel(channel.id);
if (result.error) {
const dmFailedMessage = {
id: 'mobile.open_dm.error',
defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again."
};
setChannelDisplayName(currentDisplayName);
alertErrorWithFallback(intl, result.error, dmFailedMessage, displayValue);
} else {
this.closeChannelDrawer();
}
} else {
result = await joinChannel(currentUserId, currentTeamId, channel.id);
const result = await joinChannel(currentUserId, currentTeam.id, channel.id);
if (result.error) {
const joinFailedMessage = {
id: 'mobile.join_channel.error',
defaultMessage: "We couldn't join the channel {displayName}. Please check your connection and try again."
};
setChannelDisplayName(currentDisplayName);
alertErrorWithFallback(intl, result.error, joinFailedMessage, displayValue);
} else {
this.closeChannelDrawer();
InteractionManager.runAfterInteractions(() => {
handleSelectChannel(channel.id);
});
}
}
if (result.error) {
return;
}
this.selectChannel(result.data, currentChannelId);
};
onPageSelected = (index) => {
@@ -277,125 +230,102 @@ export default class ChannelDrawer extends Component {
onSearchEnds = () => {
//hack to update the drawer when the offset changes
const {isLandscape, isTablet} = this.props;
if (this.refs.drawer) {
this.refs.drawer._syncAfterUpdate = true; //eslint-disable-line no-underscore-dangle
}
let openDrawerOffset = DRAWER_INITIAL_OFFSET;
if (isLandscape || isTablet) {
openDrawerOffset = DRAWER_LANDSCAPE_OFFSET;
}
this.setState({openDrawerOffset});
this.refs.drawer._syncAfterUpdate = true; //eslint-disable-line no-underscore-dangle
this.setState({openDrawerOffset: DRAWER_INITIAL_OFFSET});
};
onSearchStart = () => {
if (this.refs.drawer) {
this.refs.drawer._syncAfterUpdate = true; //eslint-disable-line no-underscore-dangle
}
this.refs.drawer._syncAfterUpdate = true; //eslint-disable-line no-underscore-dangle
this.setState({openDrawerOffset: 0});
};
showTeams = () => {
if (this.drawerSwiper && this.swiperIndex === 1 && this.props.teamsCount > 1) {
this.drawerSwiper.getWrappedInstance().showTeamsPage();
const teamsCount = Object.keys(this.props.myTeamMembers).length;
if (this.swiperIndex === 1 && teamsCount > 1) {
this.refs.swiper.showTeamsPage();
}
};
resetDrawer = () => {
if (this.drawerSwiper && this.swiperIndex !== 1) {
this.drawerSwiper.getWrappedInstance().resetPage();
if (this.swiperIndex !== 1) {
this.refs.swiper.resetPage();
}
};
renderContent = () => {
const {
currentChannel,
currentTeam,
channels,
channelMembers,
navigator,
teamsCount,
myTeamMembers,
theme
} = this.props;
const {openDrawerOffset} = this.state;
const showTeams = openDrawerOffset === DRAWER_INITIAL_OFFSET && Object.keys(myTeamMembers).length > 1;
const {
openDrawerOffset
} = this.state;
const multipleTeams = teamsCount > 1;
const showTeams = openDrawerOffset !== 0 && multipleTeams;
if (this.drawerSwiper) {
if (multipleTeams) {
this.drawerSwiper.getWrappedInstance().runOnLayout();
} else if (!openDrawerOffset) {
this.drawerSwiper.getWrappedInstance().scrollToStart();
}
}
const lists = [];
if (multipleTeams) {
const teamsList = (
<View
key='teamsList'
style={style.swiperContent}
>
let teams;
if (showTeams) {
teams = (
<View style={{flex: 1, marginBottom: 10}}>
<TeamsList
closeChannelDrawer={this.closeChannelDrawer}
myTeamMembers={myTeamMembers}
navigator={navigator}
/>
</View>
);
lists.push(teamsList);
}
lists.push(
<View
key='channelsList'
style={style.swiperContent}
>
const channelsList = (
<View style={{flex: 1, marginBottom: 10}}>
<ChannelsList
navigator={navigator}
currentTeam={currentTeam}
currentChannel={currentChannel}
channels={channels}
channelMembers={channelMembers}
myTeamMembers={myTeamMembers}
theme={theme}
onSelectChannel={this.selectChannel}
onJoinChannel={this.joinChannel}
navigator={navigator}
onShowTeams={this.showTeams}
onSearchStart={this.onSearchStart}
onSearchEnds={this.onSearchEnds}
theme={theme}
/>
</View>
);
return (
<SafeAreaView
backgroundColor={theme.sidebarHeaderBg}
navigator={navigator}
<Swiper
ref='swiper'
onPageSelected={this.onPageSelected}
openDrawerOffset={openDrawerOffset}
showTeams={showTeams}
theme={theme}
>
<DrawerSwiper
ref={this.drawerSwiperRef}
onPageSelected={this.onPageSelected}
openDrawerOffset={openDrawerOffset}
showTeams={showTeams}
>
{lists}
</DrawerSwiper>
</SafeAreaView>
{teams}
{channelsList}
</Swiper>
);
};
render() {
const {children} = this.props;
const {openDrawerOffset} = this.state;
const {openDrawer, openDrawerOffset} = this.state;
return (
<Drawer
ref='drawer'
open={openDrawer}
onOpenStart={this.handleDrawerOpenStart}
onOpen={this.handleDrawerOpen}
onClose={this.handleDrawerClose}
onCloseStart={this.handleDrawerCloseStart}
onClose={this.handleDrawerClose}
captureGestures='open'
type='static'
acceptTap={true}
acceptPanOnDrawer={false}
disabled={false}
content={this.renderContent()}
tapToClose={true}
@@ -410,8 +340,6 @@ export default class ChannelDrawer extends Component {
tweenDuration={100}
tweenHandler={this.handleDrawerTween}
elevation={-5}
bottomPanOffset={Platform.OS === 'ios' ? 46 : 64}
topPanOffset={Platform.OS === 'ios' ? 64 : 46}
styles={{
main: {
shadowColor: '#000000',
@@ -429,10 +357,3 @@ export default class ChannelDrawer extends Component {
);
}
}
const style = StyleSheet.create({
swiperContent: {
flex: 1,
marginBottom: 10
}
});

View File

@@ -0,0 +1,161 @@
// 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,
TouchableHighlight,
Text,
View
} from 'react-native';
import ChanneIcon from 'app/components/channel_icon';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
export default class ChannelItem extends PureComponent {
static propTypes = {
channel: PropTypes.object.isRequired,
onSelectChannel: PropTypes.func.isRequired,
isActive: PropTypes.bool.isRequired,
hasUnread: PropTypes.bool.isRequired,
mentions: PropTypes.number.isRequired,
theme: PropTypes.object.isRequired
};
onPress = () => {
const {channel, onSelectChannel} = this.props;
setTimeout(() => {
preventDoubleTap(onSelectChannel, this, channel);
}, 100);
};
render() {
const {
channel,
theme,
mentions,
hasUnread,
isActive
} = this.props;
const style = getStyleSheet(theme);
let activeItem;
let activeText;
let unreadText;
let activeBorder;
let badge;
if (mentions && !isActive) {
badge = (
<View style={style.badgeContainer}>
<Text style={style.badge}>
{mentions}
</Text>
</View>
);
}
if (hasUnread) {
unreadText = style.textUnread;
}
if (isActive) {
activeItem = style.itemActive;
activeText = style.textActive;
activeBorder = (
<View style={style.borderActive}/>
);
}
const icon = (
<ChanneIcon
isActive={isActive}
hasUnread={hasUnread}
membersCount={channel.display_name.split(',').length}
size={16}
status={channel.status}
theme={theme}
type={channel.type}
/>
);
return (
<TouchableHighlight
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={this.onPress}
>
<View style={style.container}>
{activeBorder}
<View style={[style.item, activeItem]}>
{icon}
<Text
style={[style.text, unreadText, activeText]}
ellipsizeMode='tail'
numberOfLines={1}
>
{channel.display_name}
</Text>
{badge}
</View>
</View>
</TouchableHighlight>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
height: 44
},
borderActive: {
backgroundColor: theme.sidebarTextActiveBorder,
width: 5
},
item: {
alignItems: 'center',
height: 44,
flex: 1,
flexDirection: 'row',
paddingLeft: 16
},
itemActive: {
backgroundColor: changeOpacity(theme.sidebarTextActiveColor, 0.1),
paddingLeft: 11
},
text: {
color: changeOpacity(theme.sidebarText, 0.4),
flex: 1,
fontSize: 14,
fontWeight: '600',
lineHeight: 16,
paddingRight: 40
},
textActive: {
color: theme.sidebarTextActiveColor
},
textUnread: {
color: theme.sidebarUnreadText
},
badgeContainer: {
alignItems: 'center',
backgroundColor: theme.mentionBj,
borderRadius: 7,
height: 15,
justifyContent: 'center',
marginRight: 16,
width: 16
},
badge: {
color: theme.mentionColor,
fontSize: 10,
fontWeight: '600'
}
});
});

View File

@@ -1,206 +0,0 @@
// 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 {
Animated,
Platform,
TouchableHighlight,
Text,
View
} from 'react-native';
import {intlShape} from 'react-intl';
import Badge from 'app/components/badge';
import ChannelIcon from 'app/components/channel_icon';
import {wrapWithPreventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const {View: AnimatedView} = Animated;
export default class ChannelItem extends PureComponent {
static propTypes = {
channelId: PropTypes.string.isRequired,
currentChannelId: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
fake: PropTypes.bool,
isUnread: PropTypes.bool,
mentions: PropTypes.number.isRequired,
navigator: PropTypes.object,
onSelectChannel: PropTypes.func.isRequired,
status: PropTypes.string,
type: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired
};
static contextTypes = {
intl: intlShape
};
onPress = wrapWithPreventDoubleTap(() => {
const {channelId, currentChannelId, displayName, fake, onSelectChannel, type} = this.props;
requestAnimationFrame(() => {
onSelectChannel({id: channelId, display_name: displayName, fake, type}, currentChannelId);
});
});
onPreview = () => {
const {channelId, navigator} = this.props;
if (Platform.OS === 'ios' && navigator && this.previewRef) {
const {intl} = this.context;
navigator.push({
screen: 'ChannelPeek',
previewCommit: false,
previewView: this.previewRef,
previewActions: [{
id: 'action-mark-as-read',
title: intl.formatMessage({id: 'mobile.channel.markAsRead', defaultMessage: 'Mark As Read'})
}],
passProps: {
channelId
}
});
}
};
setPreviewRef = (ref) => {
this.previewRef = ref;
};
render() {
const {
channelId,
currentChannelId,
displayName,
isUnread,
mentions,
status,
theme,
type
} = this.props;
const style = getStyleSheet(theme);
const isActive = channelId === currentChannelId;
let extraItemStyle;
let extraTextStyle;
let extraBorder;
if (isActive) {
extraItemStyle = style.itemActive;
extraTextStyle = style.textActive;
extraBorder = (
<View style={style.borderActive}/>
);
} else if (isUnread) {
extraTextStyle = style.textUnread;
}
let badge;
if (mentions) {
badge = (
<Badge
style={style.badge}
countStyle={style.mention}
count={mentions}
minHeight={20}
minWidth={20}
onPress={this.onPress}
/>
);
}
const icon = (
<ChannelIcon
isActive={isActive}
channelId={channelId}
isUnread={isUnread}
membersCount={displayName.split(',').length}
size={16}
status={status}
theme={theme}
type={type}
/>
);
return (
<AnimatedView ref={this.setPreviewRef}>
<TouchableHighlight
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={this.onPress}
onLongPress={this.onPreview}
>
<View style={style.container}>
{extraBorder}
<View style={[style.item, extraItemStyle]}>
{icon}
<Text
style={[style.text, extraTextStyle]}
ellipsizeMode='tail'
numberOfLines={1}
>
{displayName}
</Text>
{badge}
</View>
</View>
</TouchableHighlight>
</AnimatedView>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
flexDirection: 'row',
height: 44
},
borderActive: {
backgroundColor: theme.sidebarTextActiveBorder,
width: 5
},
item: {
alignItems: 'center',
height: 44,
flex: 1,
flexDirection: 'row',
paddingLeft: 16
},
itemActive: {
backgroundColor: changeOpacity(theme.sidebarTextActiveColor, 0.1),
paddingLeft: 11
},
text: {
color: changeOpacity(theme.sidebarText, 0.4),
flex: 1,
fontSize: 14,
fontWeight: '600',
lineHeight: 16,
paddingRight: 40
},
textActive: {
color: theme.sidebarTextActiveColor
},
textUnread: {
color: theme.sidebarUnreadText
},
badge: {
backgroundColor: theme.mentionBj,
borderColor: theme.sidebarHeaderBg,
borderRadius: 10,
borderWidth: 1,
padding: 3,
position: 'relative',
right: 16
},
mention: {
color: theme.mentionColor,
fontSize: 10
}
};
});

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getCurrentChannelId, makeGetChannel, getMyChannelMember} from 'mattermost-redux/selectors/entities/channels';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import ChannelItem from './channel_item';
function makeMapStateToProps() {
const getChannel = makeGetChannel();
return (state, ownProps) => {
const channel = ownProps.channel || getChannel(state, {id: ownProps.channelId});
let member;
if (ownProps.isUnread) {
member = getMyChannelMember(state, ownProps.channelId);
}
return {
currentChannelId: getCurrentChannelId(state),
displayName: channel.display_name,
fake: channel.fake,
mentions: member ? member.mention_count : 0,
status: channel.status,
theme: getTheme(state),
type: channel.type
};
};
}
export default connect(makeMapStateToProps)(ChannelItem);

View File

@@ -1,301 +0,0 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {
Platform,
TouchableHighlight,
View
} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
import AwesomeIcon from 'react-native-vector-icons/FontAwesome';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import SearchBar from 'app/components/search_bar';
import {wrapWithPreventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import FilteredList from './filtered_list';
import List from './list';
import SwitchTeamsButton from './switch_teams_button';
class ChannelsList extends React.PureComponent {
static propTypes = {
intl: intlShape.isRequired,
navigator: PropTypes.object,
onJoinChannel: PropTypes.func.isRequired,
onSearchEnds: PropTypes.func.isRequired,
onSearchStart: PropTypes.func.isRequired,
onSelectChannel: PropTypes.func.isRequired,
onShowTeams: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired
};
constructor(props) {
super(props);
this.state = {
searching: false,
term: ''
};
MaterialIcon.getImageSource('close', 20, this.props.theme.sidebarHeaderTextColor).then((source) => {
this.closeButton = source;
});
}
onSelectChannel = (channel, currentChannelId) => {
if (channel.fake) {
this.props.onJoinChannel(channel, currentChannelId);
} else {
this.props.onSelectChannel(channel, currentChannelId);
}
if (this.refs.search_bar) {
this.refs.search_bar.cancel();
}
};
openSettingsModal = wrapWithPreventDoubleTap(() => {
const {intl, navigator, theme} = this.props;
navigator.showModal({
screen: 'Settings',
title: intl.formatMessage({id: 'mobile.routes.settings', defaultMessage: 'Settings'}),
animationType: 'slide-up',
animated: true,
backButtonTitle: '',
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
navBarBackgroundColor: theme.sidebarHeaderBg,
navBarButtonColor: theme.sidebarHeaderTextColor,
screenBackgroundColor: theme.centerChannelBg
},
navigatorButtons: {
leftButtons: [{
id: 'close-settings',
icon: this.closeButton
}]
}
});
});
onSearch = (term) => {
this.setState({term});
};
onSearchFocused = () => {
this.setState({searching: true});
this.props.onSearchStart();
};
cancelSearch = () => {
this.props.onSearchEnds();
this.setState({searching: false});
this.onSearch('');
};
render() {
const {
intl,
navigator,
onShowTeams,
theme
} = this.props;
const {searching, term} = this.state;
const styles = getStyleSheet(theme);
let settings;
let list;
if (searching) {
list = (
<FilteredList
onSelectChannel={this.onSelectChannel}
styles={styles}
term={term}
/>
);
} else {
settings = (
<TouchableHighlight
style={styles.settingsContainer}
onPress={this.openSettingsModal}
underlayColor={changeOpacity(theme.sidebarHeaderBg, 0.5)}
>
<AwesomeIcon
name='cog'
style={styles.settings}
/>
</TouchableHighlight>
);
list = (
<List
navigator={navigator}
onSelectChannel={this.onSelectChannel}
styles={styles}
/>
);
}
const searchBarInput = {
backgroundColor: changeOpacity(theme.sidebarHeaderTextColor, 0.2),
color: theme.sidebarHeaderTextColor,
fontSize: 15,
lineHeight: 66
};
const title = (
<View style={styles.searchContainer}>
<SearchBar
ref='search_bar'
placeholder={intl.formatMessage({id: 'mobile.channel_drawer.search', defaultMessage: 'Jump to...'})}
cancelTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
backgroundColor='transparent'
inputHeight={33}
inputStyle={searchBarInput}
placeholderTextColor={changeOpacity(theme.sidebarHeaderTextColor, 0.5)}
tintColorSearch={changeOpacity(theme.sidebarHeaderTextColor, 0.5)}
tintColorDelete={changeOpacity(theme.sidebarHeaderTextColor, 0.5)}
titleCancelColor={theme.sidebarHeaderTextColor}
selectionColor={changeOpacity(theme.sidebarHeaderTextColor, 0.5)}
onSearchButtonPress={this.onSearch}
onCancelButtonPress={this.cancelSearch}
onChangeText={this.onSearch}
onFocus={this.onSearchFocused}
value={term}
/>
</View>
);
return (
<View
style={styles.container}
>
<View style={styles.statusBar}>
<View style={styles.headerContainer}>
<View style={styles.switchContainer}>
<SwitchTeamsButton
searching={searching}
onShowTeams={onShowTeams}
/>
</View>
{title}
{settings}
</View>
</View>
{list}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
backgroundColor: theme.sidebarBg,
flex: 1
},
statusBar: {
backgroundColor: theme.sidebarHeaderBg
},
headerContainer: {
alignItems: 'center',
paddingLeft: 10,
backgroundColor: theme.sidebarHeaderBg,
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: changeOpacity(theme.sidebarHeaderTextColor, 0.10),
...Platform.select({
android: {
height: 46
},
ios: {
height: 44
}
})
},
header: {
color: theme.sidebarHeaderTextColor,
flex: 1,
fontSize: 17,
fontWeight: 'normal',
paddingLeft: 16
},
switchContainer: {
position: 'relative',
top: -1
},
settingsContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 10,
...Platform.select({
android: {
height: 46,
marginRight: 6
},
ios: {
height: 44,
marginRight: 8
}
})
},
settings: {
color: theme.sidebarHeaderTextColor,
fontSize: 18,
fontWeight: '300'
},
titleContainer: { // These aren't used by this component, but they are passed down to the list component
alignItems: 'center',
flex: 1,
flexDirection: 'row',
height: 48,
marginLeft: 16
},
title: {
flex: 1,
color: theme.sidebarText,
opacity: 1,
fontSize: 15,
fontWeight: '400',
letterSpacing: 0.8,
lineHeight: 18
},
searchContainer: {
flex: 1,
...Platform.select({
android: {
marginBottom: 1
},
ios: {
marginBottom: 3
}
})
},
divider: {
backgroundColor: changeOpacity(theme.sidebarText, 0.1),
height: 1
},
actionContainer: {
alignItems: 'center',
height: 48,
justifyContent: 'center',
width: 50
},
action: {
color: theme.sidebarText,
fontSize: 20,
fontWeight: '500',
lineHeight: 18
},
above: {
backgroundColor: theme.mentionBj,
top: 9
}
};
});
export default injectIntl(ChannelsList);

View File

@@ -13,6 +13,7 @@ import {
import {injectIntl, intlShape} from 'react-intl';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity} from 'app/utils/theme';
import {General} from 'mattermost-redux/constants';
@@ -24,7 +25,6 @@ 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
@@ -33,16 +33,15 @@ class FilteredList extends Component {
currentTeam: PropTypes.object.isRequired,
currentUserId: PropTypes.string,
currentChannel: PropTypes.object,
groupChannelMemberDetails: PropTypes.object,
groupChannels: PropTypes.array,
intl: intlShape.isRequired,
teammateNameDisplay: PropTypes.string,
onSelectChannel: PropTypes.func.isRequired,
otherChannels: PropTypes.array,
profiles: PropTypes.object,
teamProfiles: PropTypes.object,
searchOrder: PropTypes.array.isRequired,
pastDirectMessages: PropTypes.array,
restrictDms: PropTypes.bool.isRequired,
profiles: PropTypes.oneOfType(
PropTypes.object,
PropTypes.array
),
statuses: PropTypes.object,
styles: PropTypes.object.isRequired,
term: PropTypes.string,
@@ -51,8 +50,7 @@ class FilteredList extends Component {
static defaultProps = {
currentTeam: {},
currentChannel: {},
pastDirectMessages: []
currentChannel: {}
};
constructor(props) {
@@ -62,12 +60,6 @@ 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});
}
@@ -83,11 +75,6 @@ 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);
@@ -95,25 +82,25 @@ class FilteredList extends Component {
}
onSelectChannel = (channel) => {
const {actions, currentChannel} = this.props;
const {makeGroupMessageVisibleIfNecessary} = actions;
const {makeGroupMessageVisibleIfNecessary} = this.props.actions;
if (channel.type === General.GM_CHANNEL) {
makeGroupMessageVisibleIfNecessary(channel.id);
}
this.props.onSelectChannel(channel, currentChannel.id);
this.props.onSelectChannel(channel);
};
createChannelElement = (channel) => {
return (
<ChannelDrawerItem
ref={channel.id}
channelId={channel.id}
channel={channel}
isUnread={false}
hasUnread={false}
mentions={0}
onSelectChannel={this.onSelectChannel}
isActive={channel.isCurrent || false}
theme={this.props.theme}
/>
);
};
@@ -125,158 +112,20 @@ class FilteredList extends Component {
const text = term.toLowerCase();
return channels.filter((c) => {
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;
return c.display_name.toLowerCase().includes(text);
});
};
getSectionBuilders = () => ({
unreads: {
builder: this.buildUnreadChannelsForSearch,
id: 'mobile.channel_list.unreads',
defaultMessage: 'UNREADS'
},
channels: {
builder: this.buildChannelsForSearch,
id: 'mobile.channel_list.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,
@@ -284,30 +133,66 @@ class FilteredList extends Component {
};
});
return this.filterChannels(notMemberOf, term);
}
const favorites = favoriteChannels.filter((c) => {
return c.type !== General.DM_CHANNEL && c.type !== General.GM_CHANNEL;
});
buildSectionsForSearch = (props, term) => {
const items = [];
const {searchOrder, styles} = props;
const sectionBuilders = this.getSectionBuilders();
const unreads = this.filterChannels(unreadChannels, term);
const channels = this.filterChannels([...favorites, ...publicChannels, ...privateChannels], term).
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);
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));
if (data.length) {
const title = this.renderTitle(styles, id, defaultMessage, null, previousDataLength > 0, true);
items.push(title, ...data);
previousDataLength = data.length;
}
}
if (unreads.length) {
data.push(
this.renderTitle(styles, 'mobile.channel_list.unreads', 'UNREADS', null, false, true),
...unreads
);
}
return items;
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
};
});
};
buildData = (props, term) => {
@@ -315,7 +200,7 @@ class FilteredList extends Component {
return null;
}
return this.buildSectionsForSearch(props, term);
return this.buildChannelsForSearch(props, term);
};
renderSectionAction = (styles, action) => {
@@ -323,7 +208,7 @@ class FilteredList extends Component {
return (
<TouchableHighlight
style={styles.actionContainer}
onPress={action}
onPress={() => preventDoubleTap(action, this)}
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
>
<MaterialIcon

View File

@@ -3,130 +3,42 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {createSelector} from 'reselect';
import {searchChannels} from 'mattermost-redux/actions/channels';
import {getProfilesInTeam, searchProfiles} from 'mattermost-redux/actions/users';
import {searchProfiles} from 'mattermost-redux/actions/users';
import {makeGroupMessageVisibleIfNecessary} from 'mattermost-redux/actions/preferences';
import {General} from 'mattermost-redux/constants';
import {
getChannelsWithUnreadSection,
getCurrentChannel,
getGroupChannels,
getOtherChannels
} from 'mattermost-redux/selectors/entities/channels';
import {getGroupChannels, getOtherChannels} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUserId, getProfilesInCurrentTeam, getUsers, getUserIdsInChannels, getUserStatuses} from 'mattermost-redux/selectors/entities/users';
import {getDirectShowPreferences, getTeammateNameDisplaySetting, getTheme} from 'mattermost-redux/selectors/entities/preferences';
import Config from 'assets/config';
import {getProfilesInCurrentTeam, getUsers, getUserStatuses} from 'mattermost-redux/selectors/entities/users';
import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
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) {
function mapStateToProps(state, ownProps) {
const {currentUserId} = state.entities.users;
const profiles = getUsers(state);
let teamProfiles = {};
const restrictDms = getConfig(state).RestrictDirectMessage !== General.RESTRICT_DIRECT_MESSAGE_ANY;
if (restrictDms) {
teamProfiles = getTeamProfiles(state);
let profiles;
if (getConfig(state).RestrictDirectMessage === General.RESTRICT_DIRECT_MESSAGE_ANY) {
profiles = getUsers(state);
} else {
profiles = getProfilesInCurrentTeam(state);
}
const searchOrder = Config.DrawerSearchOrder ? Config.DrawerSearchOrder : DEFAULT_SEARCH_ORDER;
return {
channels: getChannelsWithUnreadSection(state),
currentChannel: getCurrentChannel(state),
currentUserId,
otherChannels: getOtherChannels(state),
groupChannelMemberDetails: getGroupChannelMemberDetails(state),
groupChannels: getGroupChannels(state),
profiles,
teamProfiles,
teammateNameDisplay: getTeammateNameDisplaySetting(state),
statuses: getUserStatuses(state),
searchOrder,
pastDirectMessages: pastDirectMessages(state),
restrictDms,
theme: getTheme(state)
...ownProps
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
getProfilesInTeam,
makeGroupMessageVisibleIfNecessary,
searchChannels,
searchProfiles

View File

@@ -1,16 +1,405 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {
Platform,
StyleSheet,
Text,
TouchableHighlight,
View
} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
import AwesomeIcon from 'react-native-vector-icons/FontAwesome';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import Badge from 'app/components/badge';
import SearchBar from 'app/components/search_bar';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import ChannelsList from './channels_list';
import FilteredList from './filtered_list';
import List from './list';
function mapStateToProps(state) {
return {
theme: getTheme(state)
class ChannelsList extends Component {
static propTypes = {
channels: PropTypes.object.isRequired,
channelMembers: PropTypes.object,
currentTeam: PropTypes.object.isRequired,
currentChannel: PropTypes.object,
intl: intlShape.isRequired,
myTeamMembers: PropTypes.object.isRequired,
navigator: PropTypes.object,
onJoinChannel: PropTypes.func.isRequired,
onSearchEnds: PropTypes.func.isRequired,
onSearchStart: PropTypes.func.isRequired,
onSelectChannel: PropTypes.func.isRequired,
onShowTeams: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired
};
static defaultProps = {
currentTeam: {},
currentChannel: {}
};
constructor(props) {
super(props);
this.firstUnreadChannel = null;
this.state = {
searching: false,
term: ''
};
MaterialIcon.getImageSource('close', 20, this.props.theme.sidebarHeaderTextColor).
then((source) => {
this.closeButton = source;
});
}
onSelectChannel = (channel) => {
if (channel.fake) {
this.props.onJoinChannel(channel);
} else {
this.props.onSelectChannel(channel.id);
}
this.refs.search_bar.cancel();
};
openSettingsModal = () => {
const {intl, navigator, theme} = this.props;
navigator.showModal({
screen: 'Settings',
title: intl.formatMessage({id: 'mobile.routes.settings', defaultMessage: 'Settings'}),
animationType: 'slide-up',
animated: true,
backButtonTitle: '',
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
navBarBackgroundColor: theme.sidebarHeaderBg,
navBarButtonColor: theme.sidebarHeaderTextColor,
screenBackgroundColor: theme.centerChannelBg
},
navigatorButtons: {
leftButtons: [{
id: 'close-settings',
icon: this.closeButton
}]
}
});
};
onSearch = (term) => {
this.setState({term});
};
onSearchFocused = () => {
this.setState({searching: true});
this.props.onSearchStart();
};
cancelSearch = () => {
this.props.onSearchEnds();
this.setState({searching: false});
this.onSearch('');
};
render() {
const {
currentChannel,
currentTeam,
intl,
myTeamMembers,
onShowTeams,
theme
} = this.props;
if (!currentChannel) {
return <Text>{'Loading'}</Text>;
}
const {searching, term} = this.state;
const teamMembers = Object.values(myTeamMembers);
const showMembers = teamMembers.length > 1;
const styles = getStyleSheet(theme);
let settings;
let list;
if (searching) {
const listProps = {...this.props, onSelectChannel: this.onSelectChannel, styles, term};
list = <FilteredList {...listProps}/>;
} else {
settings = (
<TouchableHighlight
style={styles.settingsContainer}
onPress={() => preventDoubleTap(this.openSettingsModal)}
underlayColor={changeOpacity(theme.sidebarHeaderBg, 0.5)}
>
<AwesomeIcon
name='cog'
style={styles.settings}
/>
</TouchableHighlight>
);
const listProps = {...this.props, onSelectChannel: this.onSelectChannel, styles};
list = <List {...listProps}/>;
}
const title = (
<View style={styles.searchContainer}>
<SearchBar
ref='search_bar'
placeholder={intl.formatMessage({id: 'mobile.channel_drawer.search', defaultMessage: 'Jump to a conversation'})}
cancelTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
backgroundColor='transparent'
inputHeight={33}
inputStyle={{
backgroundColor: changeOpacity(theme.sidebarHeaderTextColor, 0.2),
color: theme.sidebarHeaderTextColor,
fontSize: 13
}}
placeholderTextColor={changeOpacity(theme.sidebarHeaderTextColor, 0.5)}
tintColorSearch={changeOpacity(theme.sidebarHeaderTextColor, 0.8)}
tintColorDelete={changeOpacity(theme.sidebarHeaderTextColor, 0.5)}
titleCancelColor={theme.sidebarHeaderTextColor}
onSearchButtonPress={this.onSearch}
onCancelButtonPress={this.cancelSearch}
onChangeText={this.onSearch}
onFocus={this.onSearchFocused}
value={term}
/>
</View>
);
let badge;
let switcher;
if (showMembers && !searching) {
let mentionCount = 0;
let messageCount = 0;
teamMembers.forEach((m) => {
if (m.team_id !== currentTeam.id) {
mentionCount = mentionCount + (m.mention_count || 0);
messageCount = messageCount + (m.msg_count || 0);
}
});
let badgeCount = 0;
if (mentionCount) {
badgeCount = mentionCount;
} else if (messageCount) {
badgeCount = -1;
}
if (badgeCount) {
badge = (
<Badge
style={styles.badge}
countStyle={styles.mention}
count={badgeCount}
minHeight={20}
minWidth={20}
/>
);
}
switcher = (
<TouchableHighlight
onPress={() => preventDoubleTap(onShowTeams)}
underlayColor={changeOpacity(theme.sidebarHeaderBg, 0.5)}
>
<View style={styles.switcherContainer}>
<AwesomeIcon
name='chevron-left'
size={12}
color={theme.sidebarHeaderBg}
/>
<View style={styles.switcherDivider}/>
<Text style={styles.switcherTeam}>
{currentTeam.display_name.substr(0, 2).toUpperCase()}
</Text>
</View>
</TouchableHighlight>
);
}
return (
<View
style={[styles.container, showMembers ? styles.extraPadding : {}]}
>
<View style={styles.statusBar}>
<View style={styles.headerContainer}>
{switcher}
{title}
{settings}
{badge}
</View>
</View>
{list}
</View>
);
}
}
export default connect(mapStateToProps)(ChannelsList);
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
backgroundColor: theme.sidebarBg,
flex: 1
},
extraPadding: {
...Platform.select({
ios: {
paddingBottom: 10
}
})
},
statusBar: {
backgroundColor: theme.sidebarHeaderBg,
...Platform.select({
ios: {
paddingTop: 20
}
})
},
headerContainer: {
alignItems: 'center',
paddingLeft: 10,
backgroundColor: theme.sidebarHeaderBg,
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: changeOpacity(theme.sidebarHeaderTextColor, 0.10),
...Platform.select({
android: {
height: 46
},
ios: {
height: 44
}
})
},
header: {
color: theme.sidebarHeaderTextColor,
flex: 1,
fontSize: 17,
fontWeight: 'normal',
paddingLeft: 16
},
settingsContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 10,
...Platform.select({
android: {
height: 46
},
ios: {
height: 44
}
})
},
settings: {
color: theme.sidebarHeaderTextColor,
fontSize: 18,
fontWeight: '300'
},
titleContainer: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
height: 48,
marginLeft: 16
},
title: {
flex: 1,
color: theme.sidebarText,
opacity: 1,
fontSize: 15,
fontWeight: '400',
letterSpacing: 0.8,
lineHeight: 18
},
searchContainer: {
flex: 1,
...Platform.select({
android: {
marginBottom: 1
},
ios: {
marginBottom: 3
}
})
},
switcherContainer: {
alignItems: 'center',
backgroundColor: theme.sidebarHeaderTextColor,
borderRadius: 2,
flexDirection: 'row',
height: 32,
justifyContent: 'center',
marginLeft: 6,
marginRight: 10,
paddingHorizontal: 6
},
switcherDivider: {
backgroundColor: theme.sidebarHeaderBg,
height: 15,
marginHorizontal: 6,
width: 1
},
switcherTeam: {
color: theme.sidebarHeaderBg,
fontFamily: 'OpenSans',
fontSize: 14
},
badge: {
backgroundColor: theme.mentionBj,
borderColor: theme.sidebarHeaderBg,
borderRadius: 10,
borderWidth: 1,
flexDirection: 'row',
padding: 3,
position: 'absolute',
left: 5,
top: 0
},
mention: {
color: theme.mentionColor,
fontSize: 10
},
divider: {
backgroundColor: changeOpacity(theme.sidebarText, 0.1),
height: 1
},
actionContainer: {
alignItems: 'center',
height: 48,
justifyContent: 'center',
width: 50
},
action: {
color: theme.sidebarText,
fontSize: 20,
fontWeight: '500',
lineHeight: 18
},
above: {
backgroundColor: theme.mentionBj,
top: 9
},
indicatorText: {
backgroundColor: 'transparent',
color: theme.mentionColor,
fontSize: 14,
paddingVertical: 2,
paddingHorizontal: 4,
textAlign: 'center',
textAlignVertical: 'center'
}
});
});
export default injectIntl(ChannelsList);

View File

@@ -1,51 +1,34 @@
// 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';
import {
getSortedUnreadChannelIds,
getSortedFavoriteChannelIds,
getSortedPublicChannelIds,
getSortedPrivateChannelIds,
getSortedDirectChannelIds
} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId, getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users';
import {getTheme, getFavoritesPreferences} from 'mattermost-redux/selectors/entities/preferences';
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) {
function mapStateToProps(state, ownProps) {
const {config, license} = state.entities.general;
const roles = getCurrentUserId(state) ? getCurrentUserRoles(state) : '';
const unreadChannelIds = getSortedUnreadChannelIds(state);
const favoriteChannelIds = getSortedFavoriteChannelIds(state);
const publicChannelIds = getSortedPublicChannelIds(state);
const privateChannelIds = getSortedPrivateChannelIds(state);
const directChannelIds = getSortedDirectChannelIds(state);
return {
canCreatePrivateChannels: showCreateOption(config, license, General.PRIVATE_CHANNEL, isAdmin(roles), isSystemAdmin(roles)),
unreadChannelIds,
favoriteChannelIds,
publicChannelIds,
privateChannelIds,
directChannelIds,
theme: getTheme(state)
...ownProps
};
}
function areStatesEqual(next, prev) {
const equalRoles = getCurrentUserRoles(prev) === getCurrentUserRoles(next);
const equalChannels = next.entities.channels === prev.entities.channels;
const equalConfig = next.entities.general.config === prev.entities.general.config;
const equalUsers = next.entities.users.profiles === prev.entities.users.profiles;
const equalFav = getFavoritesPreferences(next) === getFavoritesPreferences(prev);
return equalChannels && equalConfig && equalRoles && equalUsers && equalFav;
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
setChannelDisplayName
}, dispatch)
};
}
export default connect(mapStateToProps, null, null, {pure: true, areStatesEqual})(List);
export default connect(mapStateToProps, mapDispatchToProps)(List);

View File

@@ -1,150 +1,163 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import deepEqual from 'deep-equal';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {
InteractionManager,
SectionList,
FlatList,
Text,
TouchableHighlight,
View
} from 'react-native';
import {intlShape} from 'react-intl';
import {injectIntl, intlShape} from 'react-intl';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import FormattedText from 'app/components/formatted_text';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity} from 'app/utils/theme';
import {General} from 'mattermost-redux/constants';
import {debounce} from 'mattermost-redux/actions/helpers';
import ChannelItem from 'app/components/channel_drawer/channels_list/channel_item';
import UnreadIndicator from 'app/components/channel_drawer/channels_list/unread_indicator';
import {wrapWithPreventDoubleTap} from 'app/utils/tap';
import {changeOpacity} from 'app/utils/theme';
export default class List extends PureComponent {
class List extends Component {
static propTypes = {
actions: PropTypes.shape({
setChannelDisplayName: PropTypes.func.isRequired
}).isRequired,
canCreatePrivateChannels: PropTypes.bool.isRequired,
directChannelIds: PropTypes.array.isRequired,
favoriteChannelIds: PropTypes.array.isRequired,
channels: PropTypes.object.isRequired,
channelMembers: PropTypes.object,
currentChannel: PropTypes.object,
intl: intlShape.isRequired,
navigator: PropTypes.object,
onSelectChannel: PropTypes.func.isRequired,
publicChannelIds: PropTypes.array.isRequired,
privateChannelIds: PropTypes.array.isRequired,
styles: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
unreadChannelIds: PropTypes.array.isRequired
theme: PropTypes.object.isRequired
};
static contextTypes = {
intl: intlShape
static defaultProps = {
currentChannel: {}
};
constructor(props) {
super(props);
this.firstUnreadChannel = null;
this.state = {
sections: this.buildSections(props),
showIndicator: false,
width: 0
dataSource: this.buildData(props),
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;
});
}
shouldComponentUpdate(nextProps, nextState) {
return !deepEqual(this.props, nextProps, {strict: true}) || !deepEqual(this.state, nextState, {strict: true});
}
componentWillReceiveProps(nextProps) {
const {
canCreatePrivateChannels,
directChannelIds,
favoriteChannelIds,
publicChannelIds,
privateChannelIds,
unreadChannelIds
} = this.props;
if (nextProps.canCreatePrivateChannels !== canCreatePrivateChannels ||
nextProps.directChannelIds !== directChannelIds || nextProps.favoriteChannelIds !== favoriteChannelIds ||
nextProps.publicChannelIds !== publicChannelIds || nextProps.privateChannelIds !== privateChannelIds ||
nextProps.unreadChannelIds !== unreadChannelIds) {
this.setState({sections: this.buildSections(nextProps)});
}
this.setState({
dataSource: this.buildData(nextProps)
}, () => {
if (this.refs.list) {
this.refs.list.recordInteraction();
this.updateUnreadIndicators({
viewableItems: Array.from(this.refs.list._listRef._viewabilityHelper._viewableItems.values()) //eslint-disable-line
});
}
});
}
componentDidUpdate(prevProps, prevState) {
if (prevState.sections !== this.state.sections && this.refs.list._wrapperListRef.getListRef()._viewabilityHelper) { //eslint-disable-line
this.refs.list.recordInteraction();
this.updateUnreadIndicators({
viewableItems: Array.from(this.refs.list._wrapperListRef.getListRef()._viewabilityHelper._viewableItems.values()) //eslint-disable-line
updateUnreadIndicators = ({viewableItems}) => {
let showAbove = false;
const visibleIndexes = viewableItems.map((v) => v.index);
if (visibleIndexes.length) {
const {dataSource} = this.state;
const firstVisible = parseInt(visibleIndexes[0], 10);
if (this.firstUnreadChannel) {
const index = dataSource.findIndex((item) => {
return item.display_name === this.firstUnreadChannel;
});
showAbove = index < firstVisible;
}
this.setState({
showAbove
});
}
}
buildSections = (props) => {
const {
canCreatePrivateChannels,
directChannelIds,
favoriteChannelIds,
publicChannelIds,
privateChannelIds,
unreadChannelIds
} = props;
const sections = [];
if (unreadChannelIds.length) {
sections.push({
id: 'mobile.channel_list.unreads',
defaultMessage: 'UNREADS',
data: unreadChannelIds,
renderItem: this.renderUnreadItem,
topSeparator: false,
bottomSeparator: true
});
}
if (favoriteChannelIds.length) {
sections.push({
id: 'sidebar.favorite',
defaultMessage: 'FAVORITES',
data: favoriteChannelIds,
topSeparator: unreadChannelIds.length > 0,
bottomSeparator: true
});
}
sections.push({
action: this.goToMoreChannels,
id: 'sidebar.channels',
defaultMessage: 'PUBLIC CHANNELS',
data: publicChannelIds,
topSeparator: favoriteChannelIds.length > 0 || unreadChannelIds.length > 0,
bottomSeparator: publicChannelIds.length > 0
});
sections.push({
action: canCreatePrivateChannels ? this.goToCreatePrivateChannel : null,
id: 'sidebar.pg',
defaultMessage: 'PRIVATE CHANNELS',
data: privateChannelIds,
topSeparator: true,
bottomSeparator: privateChannelIds.length > 0
});
sections.push({
action: this.goToDirectMessages,
id: 'sidebar.direct',
defaultMessage: 'DIRECT MESSAGES',
data: directChannelIds,
topSeparator: true,
bottomSeparator: directChannelIds.length > 0
});
return sections;
};
goToCreatePrivateChannel = wrapWithPreventDoubleTap(() => {
const {navigator, theme} = this.props;
const {intl} = this.context;
onSelectChannel = (channel) => {
this.props.actions.setChannelDisplayName(channel.display_name);
this.props.onSelectChannel(channel);
};
onLayout = (event) => {
const {width} = event.nativeEvent.layout;
this.width = width;
};
getUnreadMessages = (channel) => {
const member = this.props.channelMembers[channel.id];
let mentions = 0;
let unreadCount = 0;
if (member && channel) {
mentions = member.mention_count;
unreadCount = channel.total_msg_count - member.msg_count;
if (member.notify_props && member.notify_props.mark_unread === General.MENTION) {
unreadCount = 0;
}
}
return {
mentions,
unreadCount
};
};
findUnreadChannels = (data) => {
data.forEach((c) => {
if (c.id) {
const {mentions, unreadCount} = this.getUnreadMessages(c);
const unread = (mentions + unreadCount) > 0;
if (unread && c.id !== this.props.currentChannel.id) {
if (!this.firstUnreadChannel) {
this.firstUnreadChannel = c.display_name;
}
}
}
});
};
createChannelElement = (channel) => {
const {mentions, unreadCount} = this.getUnreadMessages(channel);
const msgCount = mentions + unreadCount;
const unread = msgCount > 0;
return (
<ChannelItem
ref={channel.id}
channel={channel}
hasUnread={unread}
mentions={mentions}
onSelectChannel={this.onSelectChannel}
isActive={channel.isCurrent}
theme={this.props.theme}
/>
);
};
createPrivateChannel = () => {
const {intl, navigator, theme} = this.props;
navigator.showModal({
screen: 'CreateChannel',
@@ -163,15 +176,74 @@ export default class List extends PureComponent {
closeButton: this.closeButton
}
});
});
};
goToDirectMessages = wrapWithPreventDoubleTap(() => {
const {navigator, theme} = this.props;
const {intl} = this.context;
buildChannels = (props) => {
const {canCreatePrivateChannels, styles} = props;
const {
unreadChannels,
favoriteChannels,
publicChannels,
privateChannels,
directAndGroupChannels
} = props.channels;
const data = [];
if (unreadChannels.length) {
data.push(
this.renderTitle(styles, 'mobile.channel_list.unreads', 'UNREADS', null, false, true),
...unreadChannels
);
}
if (favoriteChannels.length) {
data.push(
this.renderTitle(styles, 'sidebar.favorite', 'FAVORITES', null, unreadChannels.length > 0, true),
...favoriteChannels
);
}
data.push(
this.renderTitle(styles, 'sidebar.channels', 'CHANNELS', this.showMoreChannelsModal, favoriteChannels.length > 0, publicChannels.length > 0),
...publicChannels
);
let createPrivateChannel;
if (canCreatePrivateChannels) {
createPrivateChannel = this.createPrivateChannel;
}
data.push(
this.renderTitle(styles, 'sidebar.pg', 'PRIVATE CHANNELS', createPrivateChannel, true, privateChannels.length > 0),
...privateChannels
);
data.push(
this.renderTitle(styles, 'sidebar.direct', 'DIRECT MESSAGES', this.showDirectMessagesModal, true, directAndGroupChannels.length > 0),
...directAndGroupChannels
);
return data;
};
buildData = (props) => {
if (!props.currentChannel) {
return null;
}
const data = this.buildChannels(props);
this.firstUnreadChannel = null;
this.findUnreadChannels(data);
return data;
};
showDirectMessagesModal = () => {
const {intl, navigator, theme} = this.props;
navigator.showModal({
screen: 'MoreDirectMessages',
title: intl.formatMessage({id: 'mobile.more_dms.title', defaultMessage: 'New Conversation'}),
title: intl.formatMessage({id: 'more_direct_channels.title', defaultMessage: 'Direct Messages'}),
animationType: 'slide-up',
animated: true,
backButtonTitle: '',
@@ -188,11 +260,10 @@ export default class List extends PureComponent {
}]
}
});
});
};
goToMoreChannels = wrapWithPreventDoubleTap(() => {
const {navigator, theme} = this.props;
const {intl} = this.context;
showMoreChannelsModal = () => {
const {intl, navigator, theme} = this.props;
navigator.showModal({
screen: 'MoreChannels',
@@ -207,21 +278,10 @@ export default class List extends PureComponent {
screenBackgroundColor: theme.centerChannelBg
},
passProps: {
channelType: General.PRIVATE_CHANNEL,
closeButton: this.closeButton
}
});
});
keyExtractor = (item) => item.id || item;
onSelectChannel = (channel, currentChannelId) => {
const {onSelectChannel} = this.props;
onSelectChannel(channel, currentChannelId);
};
onLayout = (event) => {
const {width} = event.nativeEvent.layout;
this.setState({width: width - 40});
};
renderSectionAction = (styles, action) => {
@@ -229,7 +289,7 @@ export default class List extends PureComponent {
return (
<TouchableHighlight
style={styles.actionContainer}
onPress={action}
onPress={() => preventDoubleTap(action, this)}
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
>
<MaterialIcon
@@ -240,118 +300,86 @@ export default class List extends PureComponent {
);
};
renderSectionSeparator = () => {
const {styles} = this.props;
renderDivider = (styles, marginLeft) => {
return (
<View style={[styles.divider]}/>
<View
style={[styles.divider, {marginLeft}]}
/>
);
};
renderItem = ({item}) => {
return (
<ChannelItem
channelId={item}
navigator={this.props.navigator}
onSelectChannel={this.onSelectChannel}
/>
);
};
renderUnreadItem = ({item}) => {
return (
<ChannelItem
channelId={item}
isUnread={true}
navigator={this.props.navigator}
onSelectChannel={this.onSelectChannel}
/>
);
};
renderSectionHeader = ({section}) => {
const {styles} = this.props;
const {intl} = this.context;
const {
action,
bottomSeparator,
defaultMessage,
id,
topSeparator
} = section;
return (
<View>
{topSeparator && this.renderSectionSeparator()}
<View style={styles.titleContainer}>
<Text style={styles.title}>
{intl.formatMessage({id, defaultMessage}).toUpperCase()}
</Text>
{action && this.renderSectionAction(styles, action)}
</View>
{bottomSeparator && this.renderSectionSeparator()}
</View>
);
};
scrollToTop = () => {
if (this.refs.list) {
this.refs.list._wrapperListRef.getListRef().scrollToOffset({ //eslint-disable-line no-underscore-dangle
x: 0,
y: 0,
animated: true
});
if (!item.isTitle) {
return this.createChannelElement(item);
}
return item.title;
};
emitUnreadIndicatorChange = debounce((showIndicator) => {
this.setState({showIndicator});
}, 100);
renderTitle = (styles, id, defaultMessage, action, topDivider, bottomDivider) => {
const {formatMessage} = this.props.intl;
updateUnreadIndicators = ({viewableItems}) => {
InteractionManager.runAfterInteractions(() => {
const {unreadChannelIds} = this.props;
const firstUnread = unreadChannelIds.length && unreadChannelIds[0];
if (firstUnread && viewableItems.length) {
const isVisible = viewableItems.find((v) => v.item === firstUnread);
return this.emitUnreadIndicatorChange(!isVisible);
}
return this.emitUnreadIndicatorChange(false);
});
return {
id,
isTitle: true,
title: (
<View>
{topDivider && this.renderDivider(styles, 0)}
<View style={styles.titleContainer}>
<Text style={styles.title}>
{formatMessage({id, defaultMessage}).toUpperCase()}
</Text>
{action && this.renderSectionAction(styles, action)}
</View>
{bottomDivider && this.renderDivider(styles, 0)}
</View>
)
};
};
render() {
const {styles, theme} = this.props;
const {sections, width, showIndicator} = this.state;
const {styles} = this.props;
const {dataSource, showAbove} = this.state;
let above;
if (showAbove) {
above = (
<UnreadIndicator
style={[styles.above, {width: (this.width - 40)}]}
onPress={() => this.refs.list.scrollToOffset({x: 0, y: 0, animated: true})}
text={(
<FormattedText
style={styles.indicatorText}
id='sidebar.unreadAbove'
defaultMessage='Unread post(s) above'
/>
)}
/>
);
}
return (
<View
style={styles.container}
onLayout={this.onLayout}
>
<SectionList
<FlatList
ref='list'
sections={sections}
data={dataSource}
renderItem={this.renderItem}
renderSectionHeader={this.renderSectionHeader}
keyExtractor={this.keyExtractor}
keyExtractor={(item) => item.id}
onViewableItemsChanged={this.updateUnreadIndicators}
keyboardDismissMode='on-drag'
maxToRenderPerBatch={10}
stickySectionHeadersEnabled={false}
viewabilityConfig={{
viewAreaCoveragePercentThreshold: 3,
waitForInteraction: true
waitForInteraction: false
}}
/>
<UnreadIndicator
show={showIndicator}
style={[styles.above, {width}]}
onPress={this.scrollToTop}
theme={theme}
/>
{above}
</View>
);
}
}
export default injectIntl(List);

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeam, getMyTeamsCount, getChannelDrawerBadgeCount} from 'mattermost-redux/selectors/entities/teams';
import SwitchTeamsButton from './switch_teams_button';
function mapStateToProps(state) {
const team = getCurrentTeam(state);
return {
currentTeamId: team.id,
displayName: team.display_name,
mentionCount: getChannelDrawerBadgeCount(state),
teamsCount: getMyTeamsCount(state),
theme: getTheme(state)
};
}
export default connect(mapStateToProps)(SwitchTeamsButton);

View File

@@ -1,126 +0,0 @@
// 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 {
Text,
TouchableHighlight,
View
} from 'react-native';
import AwesomeIcon from 'react-native-vector-icons/FontAwesome';
import Badge from 'app/components/badge';
import {wrapWithPreventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
export default class SwitchTeamsButton extends React.PureComponent {
static propTypes = {
currentTeamId: PropTypes.string,
displayName: PropTypes.string,
searching: PropTypes.bool.isRequired,
onShowTeams: PropTypes.func.isRequired,
mentionCount: PropTypes.number.isRequired,
teamsCount: PropTypes.number.isRequired,
theme: PropTypes.object.isRequired
};
showTeams = wrapWithPreventDoubleTap(() => {
this.props.onShowTeams();
});
render() {
const {
currentTeamId,
displayName,
mentionCount,
searching,
teamsCount,
theme
} = this.props;
if (!currentTeamId) {
return null;
}
if (searching || teamsCount < 2) {
return null;
}
const styles = getStyleSheet(theme);
const badge = (
<Badge
style={styles.badge}
countStyle={styles.mention}
count={mentionCount}
minHeight={20}
minWidth={20}
/>
);
return (
<View>
<TouchableHighlight
onPress={this.showTeams}
underlayColor={changeOpacity(theme.sidebarHeaderBg, 0.5)}
>
<View style={styles.switcherContainer}>
<AwesomeIcon
name='chevron-left'
size={12}
color={theme.sidebarHeaderBg}
/>
<View style={styles.switcherDivider}/>
<Text style={styles.switcherTeam}>
{displayName.substr(0, 2).toUpperCase()}
</Text>
</View>
</TouchableHighlight>
{badge}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
switcherContainer: {
alignItems: 'center',
backgroundColor: theme.sidebarHeaderTextColor,
borderRadius: 2,
flexDirection: 'row',
height: 32,
justifyContent: 'center',
marginLeft: 6,
marginRight: 5,
paddingHorizontal: 6
},
switcherDivider: {
backgroundColor: theme.sidebarHeaderBg,
height: 15,
marginHorizontal: 6,
width: 1
},
switcherTeam: {
color: theme.sidebarHeaderBg,
fontFamily: 'OpenSans',
fontSize: 14
},
badge: {
backgroundColor: theme.mentionBj,
borderColor: theme.sidebarHeaderBg,
borderRadius: 10,
borderWidth: 1,
flexDirection: 'row',
padding: 3,
position: 'absolute',
left: -5,
top: -5
},
mention: {
color: theme.mentionColor,
fontSize: 10
}
};
});

View File

@@ -0,0 +1,49 @@
// 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,
TouchableWithoutFeedback,
View,
ViewPropTypes
} from 'react-native';
export default class UnreadIndicator extends PureComponent {
static propTypes = {
style: ViewPropTypes.style,
textStyle: Text.propTypes.style,
text: PropTypes.node.isRequired,
onPress: PropTypes.func
};
static defaultProps = {
onPress: () => true
};
render() {
return (
<TouchableWithoutFeedback onPress={this.props.onPress}>
<View
style={[Styles.container, this.props.style]}
>
{this.props.text}
</View>
</TouchableWithoutFeedback>
);
}
}
const Styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
position: 'absolute',
borderRadius: 15,
marginHorizontal: 15,
height: 25
}
});

View File

@@ -1,84 +0,0 @@
// 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 {
TouchableWithoutFeedback,
View,
ViewPropTypes
} from 'react-native';
import IonIcon from 'react-native-vector-icons/Ionicons';
import FormattedText from 'app/components/formatted_text';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
export default class UnreadIndicator extends PureComponent {
static propTypes = {
show: PropTypes.bool,
style: ViewPropTypes.style,
onPress: PropTypes.func,
theme: PropTypes.object.isRequired
};
static defaultProps = {
onPress: () => true
};
render() {
const {onPress, show, theme} = this.props;
if (!show) {
return null;
}
const style = getStyleSheet(theme);
return (
<TouchableWithoutFeedback onPress={onPress}>
<View
style={[style.container, this.props.style]}
>
<FormattedText
style={style.indicatorText}
id='sidebar.unreads'
defaultMessage='More unreads'
/>
<IonIcon
size={14}
name='md-arrow-round-up'
color={theme.mentionColor}
style={style.arrow}
/>
</View>
</TouchableWithoutFeedback>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
position: 'absolute',
borderRadius: 15,
marginHorizontal: 15,
height: 25
},
indicatorText: {
backgroundColor: 'transparent',
color: theme.mentionColor,
fontSize: 14,
paddingVertical: 2,
paddingHorizontal: 4,
textAlign: 'center',
textAlignVertical: 'center'
},
arrow: {
position: 'relative',
bottom: -1
}
};
});

View File

@@ -1,97 +0,0 @@
// 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 {StyleSheet} from 'react-native';
import {changeOpacity} from 'app/utils/theme';
import Swiper from 'app/components/swiper';
export default class DrawerSwiper extends Component {
static propTypes = {
children: PropTypes.node.isRequired,
deviceWidth: PropTypes.number.isRequired,
onPageSelected: PropTypes.func,
openDrawerOffset: PropTypes.number,
showTeams: PropTypes.bool.isRequired,
theme: PropTypes.object.isRequired
};
static defaultProps = {
onPageSelected: () => true,
openDrawerOffset: 0
};
shouldComponentUpdate(nextProps) {
const {deviceWidth, showTeams, theme} = this.props;
return nextProps.deviceWidth !== deviceWidth ||
nextProps.showTeams !== showTeams || nextProps.theme !== theme;
}
runOnLayout = (shouldRun = true) => {
if (this.refs.swiper) {
this.refs.swiper.runOnLayout = shouldRun;
}
};
resetPage = () => {
if (this.refs.swiper) {
this.refs.swiper.scrollToIndex(1, false);
}
};
scrollToStart = () => {
if (this.refs.swiper) {
this.refs.swiper.scrollToStart();
}
};
swiperPageSelected = (index) => {
this.props.onPageSelected(index);
};
showTeamsPage = () => {
if (this.refs.swiper) {
this.refs.swiper.scrollToIndex(0, true);
}
};
render() {
const {
children,
deviceWidth,
openDrawerOffset,
showTeams,
theme
} = this.props;
const initialPage = React.Children.count(children) - 1;
return (
<Swiper
ref='swiper'
initialPage={initialPage}
onIndexChanged={this.swiperPageSelected}
paginationStyle={style.pagination}
width={deviceWidth - openDrawerOffset}
style={{backgroundColor: theme.sidebarBg}}
activeDotColor={theme.sidebarText}
dotColor={changeOpacity(theme.sidebarText, 0.5)}
scrollEnabled={showTeams}
showsPagination={showTeams}
keyboardShouldPersistTaps={'always'}
>
{children}
</Swiper>
);
}
}
const style = StyleSheet.create({
pagination: {
bottom: 0,
position: 'absolute'
}
});

View File

@@ -1,19 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getDimensions} from 'app/selectors/device';
import DraweSwiper from './drawer_swiper';
function mapStateToProps(state) {
return {
...getDimensions(state),
theme: getTheme(state)
};
}
export default connect(mapStateToProps, null, null, {withRef: true})(DraweSwiper);

View File

@@ -4,26 +4,29 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {joinChannel, markChannelAsRead, markChannelAsViewed} from 'mattermost-redux/actions/channels';
import {joinChannel, viewChannel, markChannelAsRead} from 'mattermost-redux/actions/channels';
import {getTeams} from 'mattermost-redux/actions/teams';
import {getCurrentTeamId, getMyTeamsCount} from 'mattermost-redux/selectors/entities/teams';
import {getChannelsWithUnreadSection, getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeam, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams';
import {handleSelectChannel, setChannelDisplayName, setChannelLoading} from 'app/actions/views/channel';
import {makeDirectChannel} from 'app/actions/views/more_dms';
import {isLandscape, isTablet} from 'app/selectors/device';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getTheme} from 'app/selectors/preferences';
import ChannelDrawer from './channel_drawer.js';
function mapStateToProps(state) {
function mapStateToProps(state, ownProps) {
const {currentUserId} = state.entities.users;
return {
currentTeamId: getCurrentTeamId(state),
...ownProps,
currentTeam: getCurrentTeam(state) || {},
currentChannel: getCurrentChannel(state) || {},
currentDisplayName: state.views.channel.displayName,
currentUserId,
isLandscape: isLandscape(state),
isTablet: isTablet(state),
teamsCount: getMyTeamsCount(state),
channels: getChannelsWithUnreadSection(state),
channelMembers: state.entities.channels.myMembers,
myTeamMembers: getTeamMemberships(state),
theme: getTheme(state)
};
}
@@ -34,7 +37,7 @@ function mapDispatchToProps(dispatch) {
getTeams,
handleSelectChannel,
joinChannel,
markChannelAsViewed,
viewChannel,
makeDirectChannel,
markChannelAsRead,
setChannelDisplayName,

View File

@@ -2,5 +2,5 @@
// See License.txt for license information.
// Used to leverage the platform specific components
import MattermostManaged from './mattermost-managed';
export default MattermostManaged;
import Swiper from './swiper';
export default Swiper;

View File

@@ -0,0 +1,53 @@
// 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 {ViewPagerAndroid} from 'react-native';
export default class SwiperAndroid extends PureComponent {
static propTypes = {
children: PropTypes.node.isRequired,
onPageSelected: PropTypes.func,
showTeams: PropTypes.bool.isRequired,
theme: PropTypes.object.isRequired
};
static defaultProps = {
onPageSelected: () => true
};
swiperPageSelected = (event) => {
this.props.onPageSelected(event.nativeEvent.position);
};
showTeamsPage = () => {
this.refs.swiper.setPage(0);
this.props.onPageSelected(0);
};
resetPage = () => {
this.refs.swiper.setPageWithoutAnimation(1);
this.props.onPageSelected(1);
};
render() {
const {
children,
showTeams,
theme
} = this.props;
return (
<ViewPagerAndroid
ref='swiper'
style={{flex: 1, backgroundColor: theme.sidebarBg}}
initialPage={1}
scrollEnabled={showTeams}
onPageSelected={this.swiperPageSelected}
>
{children}
</ViewPagerAndroid>
);
}
}

View File

@@ -0,0 +1,76 @@
// 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} from 'react-native';
import Swiper from 'react-native-swiper';
import {changeOpacity} from 'app/utils/theme';
export default class SwiperIos extends PureComponent {
static propTypes = {
children: PropTypes.node.isRequired,
onPageSelected: PropTypes.func,
openDrawerOffset: PropTypes.number,
showTeams: PropTypes.bool.isRequired,
theme: PropTypes.object.isRequired
};
static defaultProps = {
onPageSelected: () => true,
openDrawerOffset: 0
};
swiperPageSelected = (e, state, context) => {
this.props.onPageSelected(context.state.index);
};
showTeamsPage = () => {
this.refs.swiper.scrollBy(-1, true);
};
resetPage = () => {
this.refs.swiper.scrollBy(1, false);
};
render() {
const {
children,
openDrawerOffset,
showTeams,
theme
} = this.props;
const pagination = {bottom: 0};
if (showTeams) {
pagination.bottom = 10;
}
// Get the dimensions here so when the orientation changes we get the right dimensions
const {height: deviceHeight, width: deviceWidth} = Dimensions.get('window');
return (
<Swiper
ref='swiper'
horizontal={true}
loop={false}
index={1}
onMomentumScrollEnd={this.swiperPageSelected}
paginationStyle={[{position: 'relative'}, pagination]}
width={deviceWidth - openDrawerOffset}
height={deviceHeight}
style={{backgroundColor: theme.sidebarBg}}
activeDotColor={theme.sidebarText}
dotColor={changeOpacity(theme.sidebarText, 0.5)}
removeClippedSubviews={true}
automaticallyAdjustContentInsets={true}
scrollEnabled={showTeams}
showsPagination={showTeams}
keyboardShouldPersistTaps={'always'}
>
{children}
</Swiper>
);
}
}

View File

@@ -4,32 +4,46 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {markChannelAsRead} from 'mattermost-redux/actions/channels';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUrl} from 'mattermost-redux/selectors/entities/general';
import {getCurrentTeamId, getJoinableTeamIds, getMySortedTeamIds} from 'mattermost-redux/selectors/entities/teams';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId, getJoinableTeams, getMyTeams} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {handleTeamChange} from 'app/actions/views/select_team';
import {getCurrentLocale} from 'app/selectors/i18n';
import {getTheme} from 'app/selectors/preferences';
import {removeProtocol} from 'app/utils/url';
import TeamsList from './teams_list';
function mapStateToProps(state) {
const locale = getCurrentLocale(state);
function mapStateToProps(state, ownProps) {
const user = getCurrentUser(state);
function sortTeams(locale, a, b) {
if (a.display_name !== b.display_name) {
return a.display_name.toLowerCase().localeCompare(b.display_name.toLowerCase(), locale, {numeric: true});
}
return a.name.toLowerCase().localeCompare(b.name.toLowerCase(), locale, {numeric: true});
}
return {
canJoinOtherTeams: getJoinableTeamIds(state).length > 0,
canCreateTeams: false,
joinableTeams: getJoinableTeams(state),
currentChannelId: getCurrentChannelId(state),
currentTeamId: getCurrentTeamId(state),
currentUrl: removeProtocol(getCurrentUrl(state)),
teamIds: getMySortedTeamIds(state, locale),
theme: getTheme(state)
teams: getMyTeams(state).sort(sortTeams.bind(null, (user.locale))),
theme: getTheme(state),
...ownProps
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
handleTeamChange
handleTeamChange,
markChannelAsRead
}, dispatch)
};
}

View File

@@ -4,58 +4,67 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
InteractionManager,
FlatList,
Platform,
StyleSheet,
Text,
TouchableHighlight,
View
} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
import IonIcon from 'react-native-vector-icons/Ionicons';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import Badge from 'app/components/badge';
import FormattedText from 'app/components/formatted_text';
import {wrapWithPreventDoubleTap} from 'app/utils/tap';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import tracker from 'app/utils/time_tracker';
import TeamsListItem from './teams_list_item';
class TeamsList extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
handleTeamChange: PropTypes.func.isRequired
handleTeamChange: PropTypes.func.isRequired,
markChannelAsRead: PropTypes.func.isRequired
}).isRequired,
canJoinOtherTeams: PropTypes.bool.isRequired,
canCreateTeams: PropTypes.bool.isRequired,
closeChannelDrawer: PropTypes.func.isRequired,
currentChannelId: PropTypes.string,
currentTeamId: PropTypes.string.isRequired,
currentUrl: PropTypes.string.isRequired,
intl: intlShape.isRequired,
joinableTeams: PropTypes.object.isRequired,
myTeamMembers: PropTypes.object.isRequired,
navigator: PropTypes.object.isRequired,
teamIds: PropTypes.array.isRequired,
teams: PropTypes.array.isRequired,
theme: PropTypes.object.isRequired
};
constructor(props) {
super(props);
MaterialIcon.getImageSource('close', 20, props.theme.sidebarHeaderTextColor).then((source) => {
MaterialIcon.getImageSource('close', 20, props.theme.sidebarHeaderTextColor).
then((source) => {
this.closeButton = source;
});
}
selectTeam = (teamId) => {
requestAnimationFrame(() => {
const {actions, closeChannelDrawer, currentTeamId} = this.props;
if (teamId !== currentTeamId) {
tracker.teamSwitch = Date.now();
actions.handleTeamChange(teamId);
}
selectTeam = (team) => {
const {actions, closeChannelDrawer, currentChannelId, currentTeamId} = this.props;
if (team.id === currentTeamId) {
closeChannelDrawer();
});
} else {
if (currentChannelId) {
actions.markChannelAsRead(currentChannelId);
}
closeChannelDrawer();
InteractionManager.runAfterInteractions(() => {
actions.handleTeamChange(team);
});
}
};
goToSelectTeam = wrapWithPreventDoubleTap(() => {
goToSelectTeam = () => {
const {currentUrl, intl, navigator, theme} = this.props;
navigator.showModal({
@@ -81,31 +90,96 @@ class TeamsList extends PureComponent {
theme
}
});
});
keyExtractor = (item) => {
return item;
};
renderItem = ({item}) => {
const {currentTeamId, currentUrl, myTeamMembers, theme} = this.props;
const styles = getStyleSheet(theme);
let current;
let badge;
if (item.id === currentTeamId) {
current = (
<View style={styles.checkmarkContainer}>
<IonIcon
name='md-checkmark'
style={styles.checkmark}
/>
</View>
);
}
const member = myTeamMembers[item.id];
let badgeCount = 0;
if (member.mention_count) {
badgeCount = member.mention_count;
} else if (member.msg_count) {
badgeCount = -1;
}
if (badgeCount) {
badge = (
<Badge
style={styles.badge}
countStyle={styles.mention}
count={badgeCount}
minHeight={20}
minWidth={20}
/>
);
}
return (
<TeamsListItem
selectTeam={this.selectTeam}
teamId={item}
/>
<View style={styles.teamWrapper}>
<TouchableHighlight
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={() => {
setTimeout(() => {
preventDoubleTap(this.selectTeam, this, item);
}, 100);
}}
>
<View style={styles.teamContainer}>
<View style={styles.teamIconContainer}>
<Text style={styles.teamIcon}>
{item.display_name.substr(0, 2).toUpperCase()}
</Text>
</View>
<View style={styles.teamNameContainer}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.teamName}
>
{item.display_name}
</Text>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.teamUrl}
>
{`${currentUrl}/${item.name}`}
</Text>
</View>
{current}
</View>
</TouchableHighlight>
{badge}
</View>
);
};
render() {
const {canJoinOtherTeams, teamIds, theme} = this.props;
const {joinableTeams, teams, theme} = this.props;
const styles = getStyleSheet(theme);
let moreAction;
if (canJoinOtherTeams) {
if (Object.keys(joinableTeams).length) {
moreAction = (
<TouchableHighlight
style={styles.moreActionContainer}
onPress={this.goToSelectTeam}
onPress={() => preventDoubleTap(this.goToSelectTeam)}
underlayColor={changeOpacity(theme.sidebarHeaderBg, 0.5)}
>
<Text
@@ -130,9 +204,9 @@ class TeamsList extends PureComponent {
</View>
</View>
<FlatList
data={teamIds}
data={teams}
renderItem={this.renderItem}
keyExtractor={this.keyExtractor}
keyExtractor={(item) => item.id}
viewabilityConfig={{
viewAreaCoveragePercentThreshold: 3,
waitForInteraction: false
@@ -144,13 +218,18 @@ class TeamsList extends PureComponent {
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
return StyleSheet.create({
container: {
backgroundColor: theme.sidebarBg,
flex: 1
},
statusBar: {
backgroundColor: theme.sidebarHeaderBg
backgroundColor: theme.sidebarHeaderBg,
...Platform.select({
ios: {
paddingTop: 20
}
})
},
headerContainer: {
alignItems: 'center',
@@ -190,8 +269,66 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
moreAction: {
color: theme.sidebarHeaderTextColor,
fontSize: 30
},
teamWrapper: {
marginTop: 20
},
teamContainer: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
marginHorizontal: 16
},
teamIconContainer: {
alignItems: 'center',
backgroundColor: theme.sidebarText,
borderRadius: 2,
height: 40,
justifyContent: 'center',
width: 40
},
teamIcon: {
color: theme.sidebarBg,
fontFamily: 'OpenSans',
fontSize: 18,
fontWeight: '600'
},
teamNameContainer: {
flex: 1,
flexDirection: 'column',
marginLeft: 10
},
teamName: {
color: theme.sidebarText,
fontSize: 18
},
teamUrl: {
color: changeOpacity(theme.sidebarText, 0.5),
fontSize: 12
},
checkmarkContainer: {
alignItems: 'flex-end'
},
checkmark: {
color: theme.sidebarText,
fontSize: 20
},
badge: {
backgroundColor: theme.mentionBj,
borderColor: theme.sidebarHeaderBg,
borderRadius: 10,
borderWidth: 1,
flexDirection: 'row',
padding: 3,
position: 'absolute',
left: 45,
top: -7.5
},
mention: {
color: theme.mentionColor,
fontSize: 10
}
};
});
});
export default injectIntl(TeamsList);

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getCurrentUrl} from 'mattermost-redux/selectors/entities/general';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId, getTeam, makeGetBadgeCountForTeamId} from 'mattermost-redux/selectors/entities/teams';
import {removeProtocol} from 'app/utils/url';
import TeamsListItem from './teams_list_item.js';
function makeMapStateToProps() {
const getMentionCount = makeGetBadgeCountForTeamId();
return function mapStateToProps(state, ownProps) {
const team = getTeam(state, ownProps.teamId);
return {
currentTeamId: getCurrentTeamId(state),
currentUrl: removeProtocol(getCurrentUrl(state)),
displayName: team.display_name,
mentionCount: getMentionCount(state, ownProps.teamId),
name: team.name,
theme: getTheme(state)
};
};
}
export default connect(makeMapStateToProps)(TeamsListItem);

View File

@@ -1,165 +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 {
Text,
TouchableHighlight,
View
} from 'react-native';
import IonIcon from 'react-native-vector-icons/Ionicons';
import Badge from 'app/components/badge';
import {wrapWithPreventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
export default class TeamsListItem extends React.PureComponent {
static propTypes = {
currentTeamId: PropTypes.string.isRequired,
currentUrl: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
mentionCount: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
selectTeam: PropTypes.func.isRequired,
teamId: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired
};
selectTeam = wrapWithPreventDoubleTap(() => {
this.props.selectTeam(this.props.teamId);
});
render() {
const {
currentTeamId,
currentUrl,
displayName,
mentionCount,
name,
teamId,
theme
} = this.props;
const styles = getStyleSheet(theme);
let current;
if (teamId === currentTeamId) {
current = (
<View style={styles.checkmarkContainer}>
<IonIcon
name='md-checkmark'
style={styles.checkmark}
/>
</View>
);
}
const badge = (
<Badge
style={styles.badge}
countStyle={styles.mention}
count={mentionCount}
minHeight={20}
minWidth={20}
/>
);
return (
<View style={styles.teamWrapper}>
<TouchableHighlight
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={this.selectTeam}
>
<View style={styles.teamContainer}>
<View style={styles.teamIconContainer}>
<Text style={styles.teamIcon}>
{displayName.substr(0, 2).toUpperCase()}
</Text>
</View>
<View style={styles.teamNameContainer}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.teamName}
>
{displayName}
</Text>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.teamUrl}
>
{`${currentUrl}/${name}`}
</Text>
</View>
{current}
</View>
</TouchableHighlight>
{badge}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
teamWrapper: {
marginTop: 20
},
teamContainer: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
marginHorizontal: 16
},
teamIconContainer: {
alignItems: 'center',
backgroundColor: theme.sidebarText,
borderRadius: 2,
height: 40,
justifyContent: 'center',
width: 40
},
teamIcon: {
color: theme.sidebarBg,
fontFamily: 'OpenSans',
fontSize: 18,
fontWeight: '600'
},
teamNameContainer: {
flex: 1,
flexDirection: 'column',
marginLeft: 10
},
teamName: {
color: theme.sidebarText,
fontSize: 18
},
teamUrl: {
color: changeOpacity(theme.sidebarText, 0.5),
fontSize: 12
},
checkmarkContainer: {
alignItems: 'flex-end'
},
checkmark: {
color: theme.sidebarText,
fontSize: 20
},
badge: {
backgroundColor: theme.mentionBj,
borderColor: theme.sidebarHeaderBg,
borderRadius: 10,
borderWidth: 1,
flexDirection: 'row',
padding: 3,
position: 'absolute',
left: 45,
top: -7.5
},
mention: {
color: theme.mentionColor,
fontSize: 10
}
};
});

View File

@@ -4,148 +4,111 @@
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 {AwayAvatar, DndAvatar, OfflineAvatar, OnlineAvatar} from 'app/components/status_icons';
import {OnlineStatus, AwayStatus, OfflineStatus} from 'app/components/status_icons';
import {General} from 'mattermost-redux/constants';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
export default class ChannelIcon extends React.PureComponent {
static propTypes = {
isActive: PropTypes.bool,
isInfo: PropTypes.bool,
isUnread: PropTypes.bool,
membersCount: PropTypes.number,
size: PropTypes.number,
status: PropTypes.string,
theme: PropTypes.object.isRequired,
type: PropTypes.string.isRequired
};
function channelIcon(props) {
const {isActive, hasUnread, isInfo, membersCount, size, status, theme, type} = props;
const style = getStyleSheet(theme);
static defaultProps = {
isActive: false,
isInfo: false,
isUnread: false,
size: 12
};
let activeIcon;
let unreadIcon;
let activeGroupBox;
let unreadGroupBox;
let activeGroup;
let unreadGroup;
render() {
const {isActive, isUnread, isInfo, membersCount, size, status, theme, type} = this.props;
const style = getStyleSheet(theme);
if (hasUnread) {
unreadIcon = style.iconUnread;
unreadGroupBox = style.groupBoxUnread;
unreadGroup = style.groupUnread;
}
let activeIcon;
let unreadIcon;
let activeGroupBox;
let unreadGroupBox;
let activeGroup;
let unreadGroup;
let offlineColor = changeOpacity(theme.sidebarText, 0.5);
if (isActive) {
activeIcon = style.iconActive;
activeGroupBox = style.groupBoxActive;
activeGroup = style.groupActive;
}
if (isUnread) {
unreadIcon = style.iconUnread;
unreadGroupBox = style.groupBoxUnread;
unreadGroup = style.groupUnread;
}
if (isInfo) {
activeIcon = style.iconInfo;
activeGroupBox = style.groupBoxInfo;
activeGroup = style.groupInfo;
}
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 = (
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}>
<View style={[style.groupBox, unreadGroupBox, activeGroupBox, {width: size, height: size}]}>
<Text style={[style.group, unreadGroup, activeGroup, {fontSize: (size - 6)}]}>
{membersCount}
</Text>
</View>
);
} else if (type === General.DM_CHANNEL) {
switch (status) {
case General.AWAY:
icon = (
<AwayAvatar
width={size}
height={size}
color={theme.awayIndicator}
/>
);
break;
case General.DND:
icon = (
<DndAvatar
width={size}
height={size}
color={theme.dndIndicator}
/>
);
break;
case General.ONLINE:
icon = (
<OnlineAvatar
width={size}
height={size}
color={theme.onlineIndicator}
/>
);
break;
default:
icon = (
<OfflineAvatar
width={size}
height={size}
color={offlineColor}
/>
);
break;
}
}
</View>
);
}
switch (status) {
case General.ONLINE:
return (
<View style={[style.container, {width: size, height: size}]}>
{icon}
<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>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
marginRight: 12,
alignItems: 'center'
},
return StyleSheet.create({
icon: {
color: changeOpacity(theme.sidebarText, 0.4)
color: changeOpacity(theme.sidebarText, 0.4),
paddingRight: 12
},
iconActive: {
color: theme.sidebarTextActiveColor
@@ -156,6 +119,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
iconInfo: {
color: theme.centerChannelColor
},
statusIcon: {
paddingRight: 12
},
groupContainer: {
paddingRight: 12
},
groupBox: {
alignSelf: 'flex-start',
alignItems: 'center',
@@ -186,5 +155,25 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
groupInfo: {
color: theme.centerChannelColor
}
};
});
});
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;

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