Compare commits

..

7 Commits

Author SHA1 Message Date
enahum
fc179a6516 translations PR 20170905 (#882) 2017-09-05 09:46:41 -03:00
lfbrock
fea6372819 Update minimum server version (#878) 2017-09-02 21:29:47 -03:00
enahum
87e89de854 Version Bump to 49 (#876) 2017-09-01 14:50:01 -03:00
enahum
534af426c9 Version Bump to 49 (#875) 2017-09-01 14:49:48 -03:00
enahum
553f3796b1 RN-220 Add "in:" and "from:" search modifiers (#869)
* Fix search and search preview

* Add "in:" and "from:" search modifiers
2017-08-24 18:07:07 -03:00
enahum
18b3d6eec9 Fix badge display on Android (#867) 2017-08-24 11:31:03 -03:00
Harrison Healey
73c81bb863 RN-73 Fixed code block text colour being incorrect (#868) 2017-08-24 10:59:03 -03:00
346 changed files with 8401 additions and 21750 deletions

View File

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

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,70 +1,9 @@
# Mattermost Mobile Apps Changelog
## 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
- Server Versions Supported: Server v3.10+ is required, Self-Signed SSL Certificates are not yet supported
### Highlights
@@ -118,7 +57,7 @@
## v1.0 Release
- Release Date: July 10, 2017
- Server Versions Supported: Server v3.8+ is required, Self-Signed SSL Certificates are not supported
- Server Versions Supported: Server v3.8+ is required, Self-Signed SSL Certificates are not yet supported
### Highlights

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

156
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,79 +38,71 @@ 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
@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 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
@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 \
@@ -137,39 +117,24 @@ 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-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
@@ -177,31 +142,14 @@ 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-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-packager do-unsigned-android stop-packager
unsigned-ios: pre-run check-style start-packager do-unsigned-ios stop-packager
alpha:
@:

View File

@@ -8,6 +8,39 @@ This document includes a list of open source components used in Mattermost Mobil
--------
## react-native-swiper
This product contains 'react-native-swiper', A Swiper component for React Native.
* HOMEPAGE:
* https://github.com/leecade/react-native-swiper
* LICENSE:
The MIT License (MIT)
Copyright (c) 2015 斯人
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-tooltip
This product contains a modified portion of 'react-native-tooltip', A react-native component from displaying tooltip. Uses UIMenuController.
@@ -1246,169 +1279,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-circular-progress
React Native component for creating animated, circular progress with ReactART
* HOMEPAGE:
* https://github.com/bgryszko/react-native-circular-progress
* LICENSE:
The MIT License (MIT)
Copyright (c) 2015 Bart Gryszko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
## react-native-fetch-blob
A project committed to making file access and data transfer easier, efficient for React Native developers.
* HOMEPAGE:
* https://github.com/wkh237/react-native-fetch-blob#user-content-upload-example--dropbox-files-upload-api
* LICENSE:
MIT License
Copyright (c) 2017 xeiyan@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-sentry
This product contains 'react-native-sentry', the Sentry SDK for React Native
* HOMEPAGE:
* https://github.com/getsentry/react-native-sentry
* LICENSE:
The MIT License (MIT)
Copyright (c) 2017 Sentry
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
## react-native-youtube
This product contains 'react-native-youtube', A <YouTube /> component for React Native.
* HOMEPAGE:
* https://github.com/inProgress-team/react-native-youtube
* LICENSE:
The MIT License (MIT)
Copyright (c) 2015 Param Aggarwal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## youtube-video-id
This product contains 'youtube-video-id', Extracts the YouTube video ID from a url or string.
* HOMEPAGE:
* https://github.com/remarkablemark/youtube-video-id
* LICENSE:
MIT License
Copyright (c) 2016 Menglin "Mark" Xu <mark@remarkablemark.org>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -2,9 +2,6 @@
**Supported Server Versions:** 4.0+
**Supported iOS versions:** 9.3+
**Supported Android versions:** 5.0+
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.
@@ -84,8 +81,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:
@@ -149,11 +145,3 @@ If your app is working properly, you should see a grey “Connecting…” bar t
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

@@ -68,10 +68,6 @@ import com.android.build.OutputFile
apply from: "../../node_modules/react-native/react.gradle"
apply from: "../../node_modules/react-native-vector-icons/fonts.gradle"
if (System.getenv("MM_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
@@ -93,10 +89,10 @@ android {
defaultConfig {
applicationId "com.mattermost.rnbeta"
minSdkVersion 21
minSdkVersion 16
targetSdkVersion 23
versionCode 64
versionName "1.4.1"
versionCode 49
versionName "1.2.0"
multiDexEnabled true
ndk {
abiFilters "armeabi-v7a", "x86"
@@ -131,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 ->
@@ -154,7 +146,6 @@ android {
dependencies {
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')
@@ -171,17 +162,6 @@ dependencies {
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

@@ -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" />
@@ -43,19 +42,10 @@
<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"/>
<receiver android:name=".NotificationDismissReceiver" />
</application>
</manifest>

View File

@@ -7,31 +7,19 @@ 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 {
@@ -39,25 +27,18 @@ 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;
private static LinkedHashMap<String,ArrayList<Bundle>> channelIdToNotification = new LinkedHashMap<String,ArrayList<Bundle>>();
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper);
this.context = context;
}
public static void clearNotification(int notificationId, String channelId) {
public static void clearNotification(int notificationId) {
if (notificationId != -1) {
String channelId = String.valueOf(notificationId);
channelIdToNotificationCount.remove(channelId);
channelIdToNotification.remove(channelId);
final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancel(notificationId);
}
}
@@ -77,16 +58,14 @@ public class CustomPushNotification extends PushNotification {
channelIdToNotificationCount.put(channelId, count);
Object bundleArray = channelIdToNotification.get(channelId);
List list = null;
ArrayList list = null;
if (bundleArray == null) {
list = Collections.synchronizedList(new ArrayList(0));
list = new ArrayList();
} else {
list = Collections.synchronizedList((List)bundleArray);
}
synchronized (list) {
list.add(0, data);
channelIdToNotification.put(channelId, list);
list = (ArrayList)bundleArray;
}
list.add(data);
channelIdToNotification.put(channelId, list);
}
if ("clear".equals(type)) {
@@ -110,13 +89,7 @@ public class CustomPushNotification extends PushNotification {
@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);
}
}
@@ -125,10 +98,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) {
@@ -136,20 +108,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;
@@ -177,15 +143,17 @@ 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)
.setGroupSummary(true)
.setSmallIcon(smallIconResId)
.setVisibility(Notification.VISIBILITY_PRIVATE)
.setPriority(Notification.PRIORITY_HIGH)
.setAutoCancel(true);
.setPriority(Notification.PRIORITY_HIGH);
if (numMessages == 1) {
notification
@@ -197,53 +165,27 @@ public class CustomPushNotification extends PushNotification {
String summaryTitle = String.format("%s (%d)", title, numMessages);
Notification.InboxStyle style = new Notification.InboxStyle();
List<Bundle> list = new ArrayList<Bundle>(channelIdToNotification.get(channelId));
ArrayList<Bundle> list = (ArrayList<Bundle>) channelIdToNotification.get(channelId);
for (Bundle data : list){
String msg = data.getString("message");
if (msg != message) {
style.addLine(data.getString("message"));
}
style.addLine(data.getString("message"));
}
style.setBigContentTitle(message)
.setSummaryText(String.format("+%d more", (numMessages - 1)));
style.setBigContentTitle(title);
notification.setStyle(style)
.setContentTitle(summaryTitle);
// .setNumber(numMessages);
}
// Let's add a delete intent when the notification is dismissed
Intent delIntent = new Intent(mContext, NotificationDismissService.class);
Intent delIntent = new Intent(mContext, NotificationDismissReceiver.class);
delIntent.putExtra(NOTIFICATION_ID, notificationId);
PendingIntent deleteIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, delIntent, mNotificationProps);
PendingIntent deleteIntent = PendingIntent.getBroadcast(mContext, 0, delIntent, 0);
notification.setDeleteIntent(deleteIntent);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
notification.setGroup(GROUP_KEY_MESSAGES);
}
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);
}
Bitmap largeIconBitmap = BitmapFactory.decodeResource(res, largeIconResId);
if (largeIconResId != 0 && (largeIcon != null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)) {
notification.setLargeIcon(largeIconBitmap);
@@ -253,27 +195,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) {
// Each element then alternates between delay, vibrate, sleep, vibrate, sleep
notification.setVibrate(new long[] {1000, 1000, 500, 1000, 500});
}
boolean blink = notificationPreferences.getShouldBlink();
if (blink) {
notification.setLights(Color.CYAN, 500, 500);
}
return notification;
}
@@ -281,15 +202,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");

View File

@@ -2,9 +2,62 @@ package com.mattermost.rnbeta;
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);
LayoutParams layoutParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
Context context = getActivity();
final int drawableId = getImageId();
NotificationsLifecycleFacade.getInstance().LoadManagedConfig(getActivity());
imageView = new ImageView(context);
imageView.setImageResource(drawableId);
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

@@ -6,11 +6,6 @@ import android.content.Context;
import android.os.Bundle;
import com.facebook.react.ReactApplication;
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;
@@ -24,6 +19,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;
@@ -62,11 +58,7 @@ public class MainApplication extends NavigationApplication implements INotificat
new RNNotificationsPackage(this),
new LocalAuthPackage(),
new JailMonkeyPackage(),
new RNFetchBlobPackage(),
new MattermostPackage(this),
new RNSentryPackage(this),
new ReactNativeExceptionHandlerPackage(),
new ReactNativeYouTube()
new MattermostManagedPackage()
);
}

View File

@@ -10,19 +10,15 @@ import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import com.facebook.react.bridge.JavaScriptModule;
public class MattermostPackage implements ReactPackage {
private final MainApplication mApplication;
public MattermostPackage(MainApplication application) {
mApplication = application;
public class MattermostManagedPackage implements ReactPackage {
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
return Arrays.<NativeModule>asList(MattermostManagedModule.getInstance(reactContext));
}
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
return Arrays.<NativeModule>asList(
MattermostManagedModule.getInstance(reactContext),
NotificationPreferencesModule.getInstance(mApplication, reactContext)
);
public List<Class<? extends JavaScriptModule>> createJSModules() {
return Collections.emptyList();
}
@Override

View File

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

View File

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

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,49 +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 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 {
@Override
protected @Nullable HeadlessJsTaskConfig getTaskConfig(Intent intent) {
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(notificationId, channelId);
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,7 +1,6 @@
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;
@@ -11,7 +10,6 @@ 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;
@@ -145,15 +143,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;

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

@@ -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,12 +1,4 @@
rootProject.name = 'Mattermost'
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'
@@ -27,9 +19,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,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, getPostsWithRetry, getPostsBefore, getPostsSinceWithRetry, getPostThread} 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);
}
}
@@ -142,59 +137,25 @@ export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
}
export function loadPostsIfNecessaryWithRetry(channelId) {
return async (dispatch, getState) => {
return (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);
getPostsWithRetry(channelId)(dispatch, getState);
return;
}
if (received) {
dispatch({
type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
channelId,
time
});
}
const postsForChannel = postsIds.map((id) => posts[id]);
const latestPostTime = getLastCreateAt(postsForChannel);
getPostsSinceWithRetry(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) {
return async (dispatch, getState) => {
const {files} = getState().entities;
@@ -221,43 +182,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 +218,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 +273,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 +288,48 @@ 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);
dispatch(setChannelRefreshing(false));
return posts;
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 refreshChannelWithRetry(channelId) {
return (dispatch, getState) => {
getPostsWithRetry(channelId)(dispatch, getState);
};
}
@@ -426,17 +350,10 @@ export function setChannelLoading(loading = true) {
};
}
export function setChannelRefreshing(loading = true) {
export function setPostTooltipVisible(visible = true) {
return {
type: ViewTypes.SET_CHANNEL_REFRESHING,
loading
};
}
export function setChannelRetryFailed(failed = true) {
return {
type: ViewTypes.SET_CHANNEL_RETRY_FAILED,
failed
type: ViewTypes.POST_TOOLTIP_VISIBLE,
visible
};
}
@@ -455,42 +372,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 +407,7 @@ export function increasePostVisibility(channelId, focusedPostId) {
data: false,
channelId
});
return posts && posts.order.length >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
};
}

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

@@ -0,0 +1,13 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {ViewTypes} from 'app/constants';
export function connection(isOnline) {
return async (dispatch, getState) => {
dispatch({
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,17 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {addReaction} from 'mattermost-redux/actions/posts';
import {getPostIdsInCurrentChannel, makeGetPostIdsForThread} from 'mattermost-redux/selectors/entities/posts';
const getPostIdsForThread = makeGetPostIdsForThread();
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(addReaction(lastPostId, emoji));
};
}

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

@@ -1,18 +1,18 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {PostTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import {General} from 'mattermost-redux/constants';
import {getClientConfig, getLicenseConfig} from 'mattermost-redux/actions/general';
import {getPosts} from 'mattermost-redux/actions/posts';
import {getMyTeams, getMyTeamMembers, selectTeam} from 'mattermost-redux/actions/teams';
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';
import {General} from 'mattermost-redux/constants';
import {getClientConfig, getLicenseConfig} from 'mattermost-redux/actions/general';
import {markChannelAsRead, viewChannel} from 'mattermost-redux/actions/channels';
export function loadConfigAndLicense() {
return async (dispatch, getState) => {
@@ -25,39 +25,53 @@ export function loadConfigAndLicense() {
};
}
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) {
await retryGetPostsAction(getPosts(channelId), dispatch, getState);
if (teamId) {
handleTeamChange(teams[teamId], false)(dispatch, getState);
await loadChannelsIfNecessary(teamId)(dispatch, getState);
} else {
// when the notification is from a channel other than the current channel
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 setStatusBarHeight(height = 20) {
return {
type: ViewTypes.STATUSBAR_HEIGHT_CHANGED,
data: height
};
}
@@ -65,43 +79,10 @@ 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 default {
loadConfigAndLicense,
loadFromPushNotification,
purgeOfflineStore
queueNotification,
clearNotification,
goToNotification,
setStatusBarHeight
};

View File

@@ -3,36 +3,29 @@
import {batchActions} from 'redux-batched-actions';
import {markChannelAsRead, viewChannel} 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, selectChannel = true) {
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 state = getState();
const actions = [
setChannelDisplayName(''),
{type: TeamTypes.SELECT_TEAM, data: teamId}
{type: TeamTypes.SELECT_TEAM, data: team.id}
];
if (selectChannel) {
actions.push({type: ChannelTypes.SELECT_CHANNEL, data: ''});
const lastChannels = state.views.team.lastChannelForTeam[teamId] || [];
const lastChannelId = lastChannels[0] || '';
const currentChannelId = getCurrentChannelId(state);
viewChannel(currentChannelId)(dispatch, getState);
markChannelAsRead(lastChannelId, currentChannelId)(dispatch, getState);
const lastChannelId = state.views.team.lastChannelForTeam[team.id] || '';
actions.push({type: ChannelTypes.SELECT_CHANNEL, data: lastChannelId});
}
dispatch(batchActions(actions), getState);
@@ -46,7 +39,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

@@ -5,7 +5,7 @@ import {connect} from 'react-redux';
import {getUsersByUsername} from 'mattermost-redux/selectors/entities/users';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getTheme} from 'app/selectors/preferences';
import AtMention from './at_mention';

View File

@@ -1,123 +1,123 @@
// 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,
Text,
TouchableOpacity,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import {sortByUsername} from 'mattermost-redux/utils/user_utils';
import FormattedText from 'app/components/formatted_text';
import ProfilePicture from 'app/components/profile_picture';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
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 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;
const FROM_REGEX = /\bfrom:\s*(\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,
defaultChannel: PropTypes.object.isRequired,
autocompleteUsers: PropTypes.object.isRequired,
isSearch: PropTypes.bool,
matchTerm: PropTypes.string,
onChangeText: PropTypes.func.isRequired,
outChannel: PropTypes.array,
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 = {
autocompleteUsers: {},
defaultChannel: {},
isSearch: false,
value: ''
postDraft: '',
isSearch: false
};
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 {isSearch} = nextProps;
const regex = isSearch ? FROM_REGEX : AT_MENTION_REGEX;
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(regex);
if (!match || this.state.mentionComplete) {
this.setState({
mentionComplete: false,
sections: []
active: false,
matchTerm: null,
mentionComplete: false
});
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 matchTerm = isSearch ? match[1] : match[2];
if (matchTerm !== this.state.matchTerm) {
this.setState({
matchTerm
});
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 (nextProps.requestStatus !== RequestStatus.STARTED) {
const membersInChannel = this.filter(nextProps.autocompleteUsers.inChannel, matchTerm) || [];
const membersOutOfChannel = this.filter(nextProps.autocompleteUsers.outChannel, matchTerm) || [];
let data = {};
if (isSearch) {
sections.push({
id: 'mobile.suggestion.members',
defaultMessage: 'Members',
data: teamMembers,
key: 'teamMembers'
});
data = {members: membersInChannel.concat(membersOutOfChannel).sort(sortByUsername)};
} else {
if (inChannel.length) {
sections.push({
id: 'suggestion.mention.members',
defaultMessage: 'Channel Members',
data: inChannel,
key: 'inChannel'
});
if (membersInChannel.length > 0) {
data = Object.assign({}, data, {inChannel: membersInChannel});
}
if (this.checkSpecialMentions(matchTerm)) {
sections.push({
id: 'suggestion.mention.special',
defaultMessage: 'Special Mentions',
data: this.getSpecialMentions(),
key: 'special',
renderItem: this.renderSpecialMentions
});
if (this.checkSpecialMentions(matchTerm) && !isSearch) {
data = Object.assign({}, data, {specialMentions: this.getSpecialMentions()});
}
if (outChannel.length) {
sections.push({
id: 'suggestion.mention.nonmembers',
defaultMessage: 'Not in Channel',
data: outChannel,
key: 'outChannel'
});
if (membersOutOfChannel.length > 0) {
data = Object.assign({}, data, {notInChannel: membersOutOfChannel});
}
}
this.setState({
sections
active: data.hasOwnProperty('inChannel') || data.hasOwnProperty('specialMentions') || data.hasOwnProperty('notInChannel') || data.hasOwnProperty('members'),
dataSource: this.state.dataSource.cloneWithRowsAndSections(data)
});
}
}
keyExtractor = (item) => {
return item.id || item;
filter = (profiles, matchTerm) => {
const {isSearch} = this.props;
return profiles.filter((p) => {
return ((p.id !== this.props.currentUserId || isSearch) && (
p.username.toLowerCase().includes(matchTerm) || p.email.toLowerCase().includes(matchTerm) ||
p.first_name.toLowerCase().includes(matchTerm) || p.last_name.toLowerCase().includes(matchTerm)));
});
};
getSpecialMentions = () => {
@@ -144,77 +144,155 @@ export default class AtMention extends PureComponent {
};
completeMention = (mention) => {
const {cursorPosition, isSearch, onChangeText, value} = this.props;
const mentionPart = value.substring(0, cursorPosition);
const {cursorPosition, isSearch, onChangeText, postDraft} = this.props;
const mentionPart = postDraft.substring(0, cursorPosition);
let completedDraft;
if (isSearch) {
completedDraft = mentionPart.replace(AT_MENTION_SEARCH_REGEX, `from: ${mention} `);
completedDraft = mentionPart.replace(FROM_REGEX, `from: ${mention} `);
} else {
completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `);
}
if (value.length > cursorPosition) {
completedDraft += value.substring(cursorPosition);
if (postDraft.length > cursorPosition) {
completedDraft += postDraft.substring(cursorPosition);
}
onChangeText(completedDraft, true);
this.setState({mentionComplete: true});
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'
},
members: {
id: 'mobile.suggestion.members',
defaultMessage: 'Members'
}
};
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, 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 {autocompleteUsers, requestStatus} = this.props;
if (!this.state.active && (requestStatus !== RequestStatus.STARTED || requestStatus !== RequestStatus.SUCCESS)) {
// If we are not in an active state return null so nothing is rendered
// other components are not blocked.
return null;
}
const style = getStyleFromTheme(theme);
const style = getStyleFromTheme(this.props.theme);
if (
!autocompleteUsers.inChannel &&
!autocompleteUsers.outChannel &&
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 : null]}
sections={sections}
renderItem={this.renderItem}
style={style.listView}
enableEmptySections={true}
dataSource={this.state.dataSource}
renderSectionHeader={this.renderSectionHeader}
initialNumToRender={10}
renderRow={this.renderRow}
renderFooter={this.renderFooter}
pageSize={10}
initialListSize={10}
/>
);
}
@@ -222,11 +300,72 @@ export default class AtMention extends PureComponent {
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
listView: {
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: {
height: 250
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,47 +4,44 @@
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.isSearch) {
postDraft = state.views.search;
} else if (ownProps.rootId) {
const threadDraft = state.views.thread.drafts[ownProps.rootId];
if (threadDraft) {
postDraft = threadDraft.draft;
}
} else if (currentChannelId) {
const channelDraft = state.views.channel.drafts[currentChannelId];
if (channelDraft) {
postDraft = channelDraft.draft;
}
}
return {
currentUserId: state.entities.users.currentUserId,
currentChannelId,
currentTeamId: getCurrentTeamId(state),
currentTeamId: state.entities.teams.currentTeamId,
defaultChannel: getDefaultChannel(state),
matchTerm,
teamMembers,
inChannel,
outChannel,
postDraft,
autocompleteUsers: {
inChannel: getProfilesInCurrentChannel(state),
outChannel: getProfilesNotInCurrentChannel(state)
},
requestStatus: state.requests.users.autocompleteUsers.status,
theme: getTheme(state)
theme: getTheme(state),
...ownProps
};
}

View File

@@ -1,92 +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, changeOpacity} 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,
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.2),
borderLeftWidth: 1,
borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRightWidth: 1,
borderRightColor: changeOpacity(theme.centerChannelColor, 0.2)
},
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,24 +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),
...ownProps
};
}
export default connect(mapStateToProps)(AtMentionItem);

View File

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

View File

@@ -1,184 +1,236 @@
// 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,
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 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;
const CHANNEL_SEARCH_REGEX = /\b(?:in|channel):\s*(\S*)$/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,
autocompleteChannels: PropTypes.object.isRequired,
postDraft: PropTypes.string,
isSearch: PropTypes.bool,
matchTerm: PropTypes.string,
myChannels: PropTypes.array,
otherChannels: PropTypes.array,
onChangeText: PropTypes.func.isRequired,
privateChannels: PropTypes.array,
publicChannels: PropTypes.array,
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: '',
isSearch: false
};
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 {isSearch} = nextProps;
const regex = isSearch ? CHANNEL_SEARCH_REGEX : CHANNEL_MENTION_REGEX;
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(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: []
});
return;
} else if (matchTerm === null) {
// if the terms did not change but is null then we don't need to do anything
// If not match or if user clicked on a channel
if (!match || this.state.mentionComplete) {
const nextState = {
active: false,
mentionComplete: false
};
// Handle the case where the user typed a ~ first and then backspaced
if (nextProps.postDraft.length < this.props.postDraft.length) {
nextState.matchTerm = null;
}
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 = isSearch ? match[1] : 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
let startsWith;
if (isSearch) {
startsWith = match[0].startsWith(`in:${this.state.matchTerm}`) || match[0].startsWith(`channel:${this.state.matchTerm}`);
} else {
startsWith = match[0].startsWith(`~${this.state.matchTerm}`);
}
if (startsWith && (myChannels.length === 0 && otherChannels.length === 0)) {
this.setState({
active: false
});
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)
});
}
}
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 {cursorPosition, isSearch, onChangeText, postDraft} = this.props;
const mentionPart = postDraft.substring(0, cursorPosition);
let completedDraft;
if (isSearch) {
const channelOrIn = mentionPart.includes('in:') ? 'in:' : 'channel:';
completedDraft = mentionPart.replace(CHANNEL_MENTION_SEARCH_REGEX, `${channelOrIn} ${mention} `);
completedDraft = mentionPart.replace(CHANNEL_SEARCH_REGEX, `${channelOrIn} ${mention} `);
} else {
completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `);
}
if (value.length > cursorPosition) {
completedDraft += value.substring(cursorPosition);
if (postDraft.length > cursorPosition) {
completedDraft += postDraft.substring(cursorPosition);
}
onChangeText(completedDraft, true);
this.setState({mentionComplete: true});
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, 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 : null]}
sections={sections}
renderItem={this.renderItem}
style={style.listView}
enableEmptySections={true}
dataSource={this.state.dataSource}
renderSectionHeader={this.renderSectionHeader}
initialNumToRender={10}
renderRow={this.renderRow}
pageSize={10}
initialListSize={10}
/>
);
}
@@ -186,11 +238,57 @@ export default class ChannelMention extends PureComponent {
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
listView: {
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: {
height: 250
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,45 +5,42 @@ 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.isSearch) {
postDraft = state.views.search;
} else if (ownProps.rootId) {
const threadDraft = state.views.thread.drafts[ownProps.rootId];
if (threadDraft) {
postDraft = threadDraft.draft;
}
} else if (currentChannelId) {
const channelDraft = state.views.channel.drafts[currentChannelId];
if (channelDraft) {
postDraft = channelDraft.draft;
}
}
const autocompleteChannels = {
myChannels: getMyChannels(state).filter((c) => c.type !== General.DM_CHANNEL && c.type !== General.GM_CHANNEL),
otherChannels: getOtherChannels(state)
};
return {
...ownProps,
myChannels,
otherChannels,
publicChannels,
privateChannels,
currentTeamId: getCurrentTeamId(state),
matchTerm,
currentChannelId,
currentTeamId: state.entities.teams.currentTeamId,
postDraft,
autocompleteChannels,
requestStatus: state.requests.channels.getChannels.status,
theme: getTheme(state)
};

View File

@@ -1,72 +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, changeOpacity} 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,
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

@@ -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 {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),
...ownProps
};
}
export default connect(mapStateToProps)(ChannelMentionItem);

View File

@@ -13,34 +13,30 @@ import {
import Emoji from 'app/components/emoji';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
const EMOJI_REGEX = /(^|\s|^\+|^-)(:([^:\s]*))$/i;
const EMOJI_REGEX = /\B(:([^:\r\n\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,
postDraft: PropTypes.string,
theme: PropTypes.object.isRequired,
onChangeText: PropTypes.func.isRequired,
rootId: PropTypes.string,
value: PropTypes.string
onChangeText: PropTypes.func.isRequired
};
static defaultProps = {
defaultChannel: {},
value: ''
postDraft: ''
};
state = {
active: false,
dataSource: []
};
}
componentWillReceiveProps(nextProps) {
const regex = EMOJI_REGEX;
const match = nextProps.value.substring(0, nextProps.cursorPosition).match(regex);
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(regex);
if (!match || this.state.emojiComplete) {
this.setState({
@@ -51,7 +47,7 @@ export default class EmojiSuggestion extends Component {
return;
}
const matchTerm = match[3];
const matchTerm = match[2];
if (matchTerm !== this.state.matchTerm) {
this.setState({
matchTerm
@@ -74,22 +70,16 @@ export default class EmojiSuggestion extends Component {
}
completeSuggestion = (emoji) => {
const {actions, cursorPosition, onChangeText, value, rootId} = this.props;
const emojiPart = value.substring(0, cursorPosition);
const {cursorPosition, onChangeText, postDraft} = this.props;
const emojiPart = postDraft.substring(0, cursorPosition);
if (emojiPart.startsWith('+:')) {
actions.addReactionToLatestPost(emoji, rootId);
onChangeText('');
} else {
let completedDraft = emojiPart.replace(EMOJI_REGEX, `:${emoji}: `);
let completedDraft = emojiPart.replace(EMOJI_REGEX, `:${emoji}: `);
if (value.length > cursorPosition) {
completedDraft += value.substring(cursorPosition);
}
onChangeText(completedDraft);
if (postDraft.length > cursorPosition) {
completedDraft += postDraft.substring(cursorPosition);
}
onChangeText(completedDraft);
this.setState({
active: false,
emojiComplete: true
@@ -109,7 +99,7 @@ export default class EmojiSuggestion extends Component {
<View style={style.emoji}>
<Emoji
emojiName={item}
size={20}
size={10}
/>
</View>
<Text style={style.emojiName}>{`:${item}:`}</Text>

View File

@@ -3,12 +3,10 @@
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 {getTheme} from 'app/selectors/preferences';
import {EmojiIndicesByAlias} from 'app/utils/emojis';
import EmojiSuggestion from './emoji_suggestion';
@@ -25,21 +23,28 @@ const getEmojisByName = createSelector(
}
);
function mapStateToProps(state) {
function mapStateToProps(state, ownProps) {
const {currentChannelId} = state.entities.channels;
const emojis = getEmojisByName(state);
let postDraft;
if (ownProps.rootId) {
const threadDraft = state.views.thread.drafts[ownProps.rootId];
if (threadDraft) {
postDraft = threadDraft.draft;
}
} else if (currentChannelId) {
const channelDraft = state.views.channel.drafts[currentChannelId];
if (channelDraft) {
postDraft = channelDraft.draft;
}
}
return {
emojis,
postDraft,
theme: getTheme(state)
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
addReactionToLatestPost
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(EmojiSuggestion);
export default connect(mapStateToProps)(EmojiSuggestion);

View File

@@ -1,10 +1,9 @@
// 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 {
Platform,
StyleSheet,
View
} from 'react-native';
@@ -13,12 +12,31 @@ import AtMention from './at_mention';
import ChannelMention from './channel_mention';
import EmojiSuggestion from './emoji_suggestion';
export default class Autocomplete extends PureComponent {
const style = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
maxHeight: 200,
overflow: 'hidden'
},
searchContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
maxHeight: 300,
overflow: 'hidden',
zIndex: 5
}
});
export default class Autocomplete extends Component {
static propTypes = {
onChangeText: PropTypes.func.isRequired,
rootId: PropTypes.string,
isSearch: PropTypes.bool,
value: PropTypes.string
isSearch: PropTypes.bool
};
static defaultProps = {
@@ -36,10 +54,9 @@ export default class Autocomplete extends PureComponent {
};
render() {
const searchContainer = this.props.isSearch ? style.searchContainer : null;
const container = this.props.isSearch ? null : style.container;
const container = this.props.isSearch ? style.searchContainer : style.container;
return (
<View style={searchContainer}>
<View>
<View style={container}>
<AtMention
cursorPosition={this.state.cursorPosition}
@@ -58,32 +75,3 @@ export default class Autocomplete extends PureComponent {
);
}
}
const style = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
maxHeight: 200,
overflow: 'hidden'
},
searchContainer: {
elevation: 5,
flex: 1,
left: 0,
maxHeight: 250,
overflow: 'hidden',
position: 'absolute',
right: 0,
zIndex: 5,
...Platform.select({
android: {
top: 47
},
ios: {
top: 64
}
})
}
});

View File

@@ -1,106 +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,
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.2),
borderLeftWidth: 1,
borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRightWidth: 1,
borderRightColor: changeOpacity(theme.centerChannelColor, 0.2)
},
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: {
opacity: 1
}
});
}, 100);
};
renderText = () => {
@@ -107,10 +118,6 @@ export default class Badge extends PureComponent {
};
render() {
if (!this.props.count) {
return null;
}
return (
<TouchableWithoutFeedback
{...this.panResponder.panHandlers}

View File

@@ -1,14 +1,12 @@
// 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';
@@ -16,16 +14,15 @@ import Drawer from 'app/components/drawer';
import {alertErrorWithFallback} from 'app/utils/general';
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,
@@ -38,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();
}
@@ -71,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;
}
@@ -118,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 = () => {
@@ -134,6 +95,10 @@ export default class ChannelDrawer extends Component {
InteractionManager.clearInteractionHandle(this.closeLeftHandle);
this.closeLeftHandle = null;
}
if (this.state.openDrawer) {
this.setState({openDrawer: false});
}
};
handleDrawerCloseStart = () => {
@@ -143,7 +108,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();
}
@@ -177,25 +143,15 @@ 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 = (channel) => {
const {
actions
actions,
currentChannel
} = this.props;
const {
@@ -206,66 +162,72 @@ export default class ChannelDrawer extends Component {
viewChannel
} = actions;
setChannelLoading(channel.id !== currentChannelId);
markChannelAsRead(channel.id, currentChannel.id);
setChannelLoading();
viewChannel(currentChannel.id);
setChannelDisplayName(channel.display_name);
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) {
viewChannel(currentChannelId);
}
});
});
};
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) => {
@@ -274,120 +236,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 (
<DrawerSwiper
ref={this.drawerSwiperRef}
<Swiper
ref='swiper'
onPageSelected={this.onPageSelected}
openDrawerOffset={openDrawerOffset}
showTeams={showTeams}
theme={theme}
>
{lists}
</DrawerSwiper>
{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={true}
disabled={false}
content={this.renderContent()}
tapToClose={true}
@@ -402,8 +346,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',
@@ -421,10 +363,3 @@ export default class ChannelDrawer extends Component {
);
}
}
const style = StyleSheet.create({
swiperContent: {
flex: 1,
marginBottom: 10
}
});

View File

@@ -10,63 +10,45 @@ import {
} from 'react-native';
import Badge from 'app/components/badge';
import ChannelIcon from 'app/components/channel_icon';
import {wrapWithPreventDoubleTap} from 'app/utils/tap';
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 = {
channelId: PropTypes.string.isRequired,
currentChannelId: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
fake: PropTypes.bool,
isUnread: PropTypes.bool,
mentions: PropTypes.number.isRequired,
channel: PropTypes.object.isRequired,
onSelectChannel: PropTypes.func.isRequired,
status: PropTypes.string,
type: PropTypes.string.isRequired,
isActive: PropTypes.bool.isRequired,
hasUnread: PropTypes.bool.isRequired,
mentions: PropTypes.number.isRequired,
theme: PropTypes.object.isRequired
};
onPress = wrapWithPreventDoubleTap(() => {
const {channelId, currentChannelId, displayName, fake, onSelectChannel, type} = this.props;
requestAnimationFrame(() => {
onSelectChannel({id: channelId, display_name: displayName, fake, type}, currentChannelId);
});
});
onPress = () => {
const {channel, onSelectChannel} = this.props;
setTimeout(() => {
preventDoubleTap(onSelectChannel, this, channel);
}, 100);
};
render() {
const {
channelId,
currentChannelId,
displayName,
isUnread,
mentions,
status,
channel,
theme,
type
mentions,
hasUnread,
isActive
} = 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 activeItem;
let activeText;
let unreadText;
let activeBorder;
let badge;
if (mentions) {
if (mentions && !isActive) {
badge = (
<Badge
style={style.badge}
@@ -79,16 +61,28 @@ export default class ChannelItem extends PureComponent {
);
}
if (hasUnread) {
unreadText = style.textUnread;
}
if (isActive) {
activeItem = style.itemActive;
activeText = style.textActive;
activeBorder = (
<View style={style.borderActive}/>
);
}
const icon = (
<ChannelIcon
<ChanneIcon
isActive={isActive}
channelId={channelId}
isUnread={isUnread}
membersCount={displayName.split(',').length}
hasUnread={hasUnread}
membersCount={channel.display_name.split(',').length}
size={16}
status={status}
status={channel.status}
theme={theme}
type={type}
type={channel.type}
/>
);
@@ -98,15 +92,15 @@ export default class ChannelItem extends PureComponent {
onPress={this.onPress}
>
<View style={style.container}>
{extraBorder}
<View style={[style.item, extraItemStyle]}>
{activeBorder}
<View style={[style.item, activeItem]}>
{icon}
<Text
style={[style.text, extraTextStyle]}
style={[style.text, unreadText, activeText]}
ellipsizeMode='tail'
numberOfLines={1}
>
{displayName}
{channel.display_name}
</Text>
{badge}
</View>

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,304 +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.firstUnreadChannel = null;
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 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={{
backgroundColor: changeOpacity(theme.sidebarHeaderTextColor, 0.2),
color: theme.sidebarHeaderTextColor,
fontSize: 15,
lineHeight: 66
}}
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,
...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
},
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';
@@ -33,6 +34,7 @@ class FilteredList extends Component {
currentTeam: PropTypes.object.isRequired,
currentUserId: PropTypes.string,
currentChannel: PropTypes.object,
groupChannels: PropTypes.array,
groupChannelMemberDetails: PropTypes.object,
intl: intlShape.isRequired,
teammateNameDisplay: PropTypes.string,
@@ -95,25 +97,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}
/>
);
};
@@ -323,7 +325,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

@@ -9,15 +9,10 @@ import {searchChannels} from 'mattermost-redux/actions/channels';
import {getProfilesInTeam, 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 {getDirectShowPreferences, getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import Config from 'assets/config';
@@ -107,10 +102,9 @@ function mapStateToProps(state, ownProps) {
const searchOrder = Config.DrawerSearchOrder ? Config.DrawerSearchOrder : DEFAULT_SEARCH_ORDER;
return {
channels: getChannelsWithUnreadSection(state),
currentChannel: getCurrentChannel(state),
currentUserId,
otherChannels: getOtherChannels(state),
groupChannels: getGroupChannels(state),
groupChannelMemberDetails: getGroupChannelMemberDetails(state),
profiles,
teamProfiles,
@@ -119,7 +113,6 @@ function mapStateToProps(state, ownProps) {
searchOrder,
pastDirectMessages: pastDirectMessages(state),
restrictDms,
theme: getTheme(state),
...ownProps
};
}

View File

@@ -1,16 +1,406 @@
// 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,
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);
}
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}
selectionColor={changeOpacity(theme.sidebarHeaderTextColor, 0.5)}
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 {
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,
marginRight: 6
},
ios: {
height: 44,
marginRight: 8
}
})
},
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

@@ -4,48 +4,20 @@
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 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;
}
export default connect(mapStateToProps, null, null, {pure: true, areStatesEqual})(List);
export default connect(mapStateToProps, null)(List);

View File

@@ -1,11 +1,11 @@
// 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
@@ -13,36 +13,38 @@ import {
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';
class List extends PureComponent {
class List extends Component {
static propTypes = {
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 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) => {
@@ -50,96 +52,106 @@ class List extends PureComponent {
});
}
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) {
this.refs.list.recordInteraction();
this.updateUnreadIndicators({
viewableItems: Array.from(this.refs.list._wrapperListRef._listRef._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(() => {
onSelectChannel = (channel) => {
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({
@@ -159,9 +171,69 @@ class List extends PureComponent {
closeButton: this.closeButton
}
});
});
};
goToDirectMessages = wrapWithPreventDoubleTap(() => {
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({
@@ -183,9 +255,9 @@ class List extends PureComponent {
}]
}
});
});
};
goToMoreChannels = wrapWithPreventDoubleTap(() => {
showMoreChannelsModal = () => {
const {intl, navigator, theme} = this.props;
navigator.showModal({
@@ -204,18 +276,6 @@ class List extends PureComponent {
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) => {
@@ -223,7 +283,7 @@ class List extends PureComponent {
return (
<TouchableHighlight
style={styles.actionContainer}
onPress={action}
onPress={() => preventDoubleTap(action, this)}
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
>
<MaterialIcon
@@ -234,114 +294,83 @@ 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}
onSelectChannel={this.onSelectChannel}
/>
);
};
renderUnreadItem = ({item}) => {
return (
<ChannelItem
channelId={item}
isUnread={true}
onSelectChannel={this.onSelectChannel}
/>
);
};
renderSectionHeader = ({section}) => {
const {intl, styles} = this.props;
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._listRef.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>
);
}

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,48 +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 {StyleSheet, View} from 'react-native';
import Svg, {
G,
Path
} from 'react-native-svg';
export default class AboveIcon extends PureComponent {
static propTypes = {
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
color: PropTypes.string.isRequired
};
render() {
const {color, height, width} = this.props;
return (
<View style={[style.container, {height, width}]}>
<Svg
width={width}
height={height}
viewBox='0 0 10 10'
>
<G transform='matrix(1,0,0,1,-20,-18)'>
<G transform='matrix(0.0330723,0,0,0.0322634,15.8132,12.3164)'>
<Path
d='M245.803,377.493C245.803,377.493 204.794,336.485 179.398,311.088C168.55,300.24 150.962,300.24 140.114,311.088C138.327,312.875 136.517,314.686 134.73,316.473C123.882,327.321 123.882,344.908 134.73,355.756C167.972,388.998 233.949,454.975 256.949,477.975C262.158,483.184 269.223,486.111 276.591,486.111C277.38,486.111 278.176,486.111 278.965,486.111C286.332,486.111 293.397,483.184 298.607,477.975C321.607,454.975 387.584,388.998 420.826,355.756C431.674,344.908 431.674,327.321 420.826,316.473C419.039,314.686 417.228,312.875 415.441,311.088C404.593,300.24 387.005,300.24 376.158,311.088C350.761,336.485 309.753,377.493 309.753,377.493C309.753,377.493 309.753,279.687 309.753,203.94C309.753,196.573 306.826,189.508 301.617,184.298C296.408,179.089 289.342,176.162 281.975,176.162C279.191,176.162 276.364,176.162 273.58,176.162C266.213,176.162 259.148,179.089 253.939,184.298C248.729,189.508 245.803,196.573 245.803,203.94L245.803,377.493Z'
fill={color}
/>
</G>
</G>
</Svg>
</View>
);
}
}
const style = StyleSheet.create({
container: {
alignItems: 'flex-start',
transform: [{rotate: '180deg'}]
}
});

View File

@@ -1,80 +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 FormattedText from 'app/components/formatted_text';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import AboveIcon from './above_icon';
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'
/>
<AboveIcon
width={12}
height={12}
color={theme.mentionColor}
/>
</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'
}
};
});

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

@@ -6,24 +6,27 @@ import {connect} from 'react-redux';
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)
};
}

View File

@@ -1,5 +1,6 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import NotificationPreferences from './notification_preferences';
export default NotificationPreferences;
// Used to leverage the platform specific components
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,6 +4,7 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
InteractionManager,
FlatList,
Platform,
Text,
@@ -11,26 +12,30 @@ import {
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 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
};
@@ -42,18 +47,22 @@ class TeamsList extends PureComponent {
});
}
selectTeam = (teamId) => {
requestAnimationFrame(() => {
const {actions, closeChannelDrawer, currentTeamId} = this.props;
if (teamId !== currentTeamId) {
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({
@@ -79,31 +88,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
@@ -128,9 +202,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
@@ -193,6 +267,64 @@ 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
}
};
});

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

@@ -19,7 +19,7 @@ export default class ChannelIcon extends React.PureComponent {
static propTypes = {
isActive: PropTypes.bool,
isInfo: PropTypes.bool,
isUnread: PropTypes.bool,
hasUnread: PropTypes.bool,
membersCount: PropTypes.number,
size: PropTypes.number,
status: PropTypes.string,
@@ -30,12 +30,12 @@ export default class ChannelIcon extends React.PureComponent {
static defaultProps = {
isActive: false,
isInfo: false,
isUnread: false,
hasUnread: false,
size: 12
};
render() {
const {isActive, isUnread, isInfo, membersCount, size, status, theme, type} = this.props;
const {isActive, hasUnread, isInfo, membersCount, size, status, theme, type} = this.props;
const style = getStyleSheet(theme);
let activeIcon;
@@ -46,7 +46,7 @@ export default class ChannelIcon extends React.PureComponent {
let unreadGroup;
let offlineColor = changeOpacity(theme.sidebarText, 0.5);
if (isUnread) {
if (hasUnread) {
unreadIcon = style.iconUnread;
unreadGroupBox = style.groupBoxUnread;
unreadGroup = style.groupUnread;

View File

@@ -12,7 +12,6 @@ import {getFullName} from 'mattermost-redux/utils/user_utils';
import {General} from 'mattermost-redux/constants';
import {injectIntl, intlShape} from 'react-intl';
import Loading from 'app/components/loading';
import ProfilePicture from 'app/components/profile_picture';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
@@ -23,7 +22,6 @@ class ChannelIntro extends PureComponent {
currentChannel: PropTypes.object.isRequired,
currentChannelMembers: PropTypes.array.isRequired,
intl: intlShape.isRequired,
isLoadingPosts: PropTypes.bool,
navigator: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired
};
@@ -73,7 +71,7 @@ class ChannelIntro extends PureComponent {
style={style.profile}
>
<ProfilePicture
userId={member.id}
user={member}
size={64}
statusBorderWidth={2}
statusSize={25}
@@ -306,35 +304,18 @@ class ChannelIntro extends PureComponent {
};
render() {
const {currentChannel, isLoadingPosts, theme} = this.props;
const {theme} = this.props;
const style = getStyleSheet(theme);
const channelType = currentChannel.type;
if (isLoadingPosts) {
return (
<View style={style.container}>
<Loading/>
</View>
);
}
let profiles;
if (channelType === General.DM_CHANNEL || channelType === General.GM_CHANNEL) {
profiles = (
<View>
<View style={style.profilesContainer}>
{this.buildProfiles()}
</View>
<View style={style.namesContainer}>
{this.buildNames()}
</View>
</View>
);
}
return (
<View style={style.container}>
{profiles}
<View style={style.profilesContainer}>
{this.buildProfiles()}
</View>
<View style={style.namesContainer}>
{this.buildNames()}
</View>
<View style={style.contentContainer}>
{this.buildContent()}
</View>

View File

@@ -1,19 +1,19 @@
// 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, RequestStatus} from 'mattermost-redux/constants';
import {General} from 'mattermost-redux/constants';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUser, getProfilesInCurrentChannel} from 'mattermost-redux/selectors/entities/users';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getTheme} from 'app/selectors/preferences';
import ChannelIntro from './channel_intro';
function mapStateToProps(state) {
const currentChannel = getCurrentChannel(state) || {};
const currentUser = getCurrentUser(state) || {};
const {status: getPostsRequestStatus} = state.requests.posts.getPosts;
let currentChannelMembers = [];
if (currentChannel.type === General.DM_CHANNEL) {
@@ -28,15 +28,20 @@ function mapStateToProps(state) {
}
const creator = currentChannel.creator_id === currentUser.id ? currentUser : state.entities.users.profiles[currentChannel.creator_id];
const postsInChannel = state.entities.posts.postsInChannel[currentChannel.id] || [];
return {
creator,
currentChannel,
currentChannelMembers,
isLoadingPosts: !postsInChannel.length && getPostsRequestStatus === RequestStatus.STARTED,
theme: getTheme(state)
};
}
export default connect(mapStateToProps)(ChannelIntro);
function mapDispatchToProps(dispatch) {
// placeholder for invite and set header actions
return {
actions: bindActionCreators({}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ChannelIntro);

View File

@@ -0,0 +1,105 @@
// 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 {
View
} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const GRADIENT_START = 0.05;
const GRADIENT_MIDDLE = 0.1;
const GRADIENT_END = 0.01;
function buildSections(key, style, theme, top) {
return (
<View
key={key}
style={[style.section, (top && {marginTop: -15})]}
>
<View style={style.avatar}/>
<View style={style.sectionMessage}>
<LinearGradient
start={{x: 0.0, y: 1.0}}
end={{x: 1.0, y: 1.0}}
colors={[
changeOpacity('#e5e5e4', GRADIENT_START),
changeOpacity('#d6d6d5', GRADIENT_MIDDLE),
changeOpacity('#e5e5e4', GRADIENT_END)
]}
locations={[0.1, 0.3, 0.7]}
style={[style.messageText, {width: 106}]}
/>
<LinearGradient
start={{x: 0.0, y: 1.0}}
end={{x: 1.0, y: 1.0}}
colors={[
changeOpacity('#e5e5e4', GRADIENT_START),
changeOpacity('#d6d6d5', GRADIENT_MIDDLE),
changeOpacity('#e5e5e4', GRADIENT_END)
]}
locations={[0.1, 0.3, 0.7]}
style={[style.messageText, {alignSelf: 'stretch'}]}
/>
<LinearGradient
start={{x: 0.0, y: 1.0}}
end={{x: 1.0, y: 1.0}}
colors={[
changeOpacity('#e5e5e4', GRADIENT_START),
changeOpacity('#d6d6d5', GRADIENT_MIDDLE),
changeOpacity('#e5e5e4', GRADIENT_END)
]}
locations={[0.1, 0.3, 0.7]}
style={[style.messageText, {alignSelf: 'stretch'}]}
/>
</View>
</View>
);
}
export default function channelLoader(props) {
const style = getStyleSheet(props.theme);
return (
<View style={style.container}>
{Array(10).fill().map((item, index) => buildSections(index, style, props.theme, index === 0))}
</View>
);
}
channelLoader.propTypes = {
theme: PropTypes.object.isRequired
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
avatar: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
borderRadius: 16,
height: 32,
width: 32
},
container: {
backgroundColor: theme.centerChannelBg,
flex: 1
},
messageText: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
height: 10,
marginBottom: 10
},
section: {
flexDirection: 'row',
paddingLeft: 12,
paddingRight: 20,
marginVertical: 10
},
sectionMessage: {
marginLeft: 12,
flex: 1
}
};
});

View File

@@ -1,127 +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 {
Platform,
View
} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const GRADIENT_START = 0.05;
const GRADIENT_MIDDLE = 0.1;
const GRADIENT_END = 0.01;
export default class ChannelLoader extends PureComponent {
static propTypes = {
channelIsLoading: PropTypes.bool.isRequired,
deviceWidth: PropTypes.number.isRequired,
theme: PropTypes.object.isRequired
};
buildSections(key, style, top) {
return (
<View
key={key}
style={[style.section, (top && {marginTop: Platform.OS === 'android' ? 0 : -15, paddingTop: 10})]}
>
<View style={style.avatar}/>
<View style={style.sectionMessage}>
<LinearGradient
start={{x: 0.0, y: 1.0}}
end={{x: 1.0, y: 1.0}}
colors={[
changeOpacity('#e5e5e4', GRADIENT_START),
changeOpacity('#d6d6d5', GRADIENT_MIDDLE),
changeOpacity('#e5e5e4', GRADIENT_END)
]}
locations={[0.1, 0.3, 0.7]}
style={[style.messageText, {width: 106}]}
/>
<LinearGradient
start={{x: 0.0, y: 1.0}}
end={{x: 1.0, y: 1.0}}
colors={[
changeOpacity('#e5e5e4', GRADIENT_START),
changeOpacity('#d6d6d5', GRADIENT_MIDDLE),
changeOpacity('#e5e5e4', GRADIENT_END)
]}
locations={[0.1, 0.3, 0.7]}
style={[style.messageText, {alignSelf: 'stretch'}]}
/>
<LinearGradient
start={{x: 0.0, y: 1.0}}
end={{x: 1.0, y: 1.0}}
colors={[
changeOpacity('#e5e5e4', GRADIENT_START),
changeOpacity('#d6d6d5', GRADIENT_MIDDLE),
changeOpacity('#e5e5e4', GRADIENT_END)
]}
locations={[0.1, 0.3, 0.7]}
style={[style.messageText, {alignSelf: 'stretch'}]}
/>
</View>
</View>
);
}
render() {
const {channelIsLoading, deviceWidth, theme} = this.props;
if (!channelIsLoading) {
return null;
}
const style = getStyleSheet(theme);
return (
<View style={[style.container, {width: deviceWidth}]}>
{Array(20).fill().map((item, index) => this.buildSections(index, style, index === 0))}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
backgroundColor: theme.centerChannelBg,
flex: 1,
position: 'absolute',
...Platform.select({
android: {
top: 0
},
ios: {
top: 15
}
})
},
avatar: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
borderRadius: 16,
height: 32,
width: 32
},
messageText: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
height: 10,
marginBottom: 10
},
section: {
backgroundColor: theme.centerChannelBg,
flexDirection: 'row',
paddingLeft: 12,
paddingRight: 20,
marginVertical: 10
},
sectionMessage: {
marginLeft: 12,
flex: 1
}
};
});

View File

@@ -1,18 +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 ChannelLoader from './channel_loader';
function mapStateToProps(state) {
const {deviceWidth} = state.device.dimension;
return {
channelIsLoading: state.views.channel.loading,
deviceWidth,
theme: getTheme(state)
};
}
export default connect(mapStateToProps)(ChannelLoader);

View File

@@ -1,231 +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 {
Alert,
Animated,
Linking,
TouchableOpacity,
View
} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
import FormattedText from 'app/components/formatted_text';
import {UpgradeTypes} from 'app/constants/view';
import checkUpgradeType from 'app/utils/client_upgrade';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const {View: AnimatedView} = Animated;
const UPDATE_TIMEOUT = 60000;
class ClientUpgradeListener extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
logError: PropTypes.func.isRequired,
setLastUpgradeCheck: PropTypes.func.isRequired
}).isRequired,
currentVersion: PropTypes.string,
downloadLink: PropTypes.string,
forceUpgrade: PropTypes.bool,
intl: intlShape.isRequired,
lastUpgradeCheck: PropTypes.number,
latestVersion: PropTypes.string,
minVersion: PropTypes.string,
navigator: PropTypes.object,
theme: PropTypes.object.isRequired
};
state = {
top: new Animated.Value(-100)
}
componentDidMount() {
const {forceUpgrade, lastUpgradeCheck, latestVersion, minVersion} = this.props;
if (forceUpgrade || Date.now() - lastUpgradeCheck > UPDATE_TIMEOUT) {
this.checkUpgrade(minVersion, latestVersion);
}
}
componentWillReceiveProps(nextProps) {
const {forceUpgrade, latestVersion, minVersion} = this.props;
const {latestVersion: nextLatestVersion, minVersion: nextMinVersion, lastUpgradeCheck} = nextProps;
const versionMismatch = latestVersion !== nextLatestVersion || minVersion !== nextMinVersion;
if (versionMismatch && (forceUpgrade || Date.now() - lastUpgradeCheck > UPDATE_TIMEOUT)) {
this.checkUpgrade(minVersion, latestVersion);
}
}
checkUpgrade = (minVersion, latestVersion) => {
const {actions, currentVersion} = this.props;
const upgradeType = checkUpgradeType(currentVersion, minVersion, latestVersion, actions.logError);
if (upgradeType === UpgradeTypes.NO_UPGRADE) {
return;
}
this.setState({upgradeType});
setTimeout(this.toggleUpgradeMessage, 500);
actions.setLastUpgradeCheck();
}
toggleUpgradeMessage = (show = true) => {
const toValue = show ? 75 : -100;
Animated.timing(this.state.top, {
toValue,
duration: 300
}).start();
}
handleDismiss = () => {
this.toggleUpgradeMessage(false);
}
handleDownload = () => {
const {downloadLink, intl} = this.props;
Linking.canOpenURL(downloadLink).then((supported) => {
if (supported) {
return Linking.openURL(downloadLink);
}
Alert.alert(
intl.formatMessage({
id: 'mobile.client_upgrade.download_error.title',
defaultMessage: 'Upgrade Error'
}),
intl.formatMessage({
id: 'mobile.client_upgrade.download_error.message',
defaultMessage: 'An error occurred while trying to open the download link.'
})
);
return false;
});
this.toggleUpgradeMessage(false);
}
handleLearnMore = () => {
this.props.navigator.dismissAllModals({animationType: 'none'});
this.props.navigator.showModal({
screen: 'ClientUpgrade',
navigatorStyle: {
navBarHidden: true,
statusBarHidden: true,
statusBarHideWithNavBar: true
},
passProps: {
upgradeType: this.state.upgradeType
}
});
this.toggleUpgradeMessage(false);
}
render() {
const {forceUpgrade, theme} = this.props;
const styles = getStyleSheet(theme);
return (
<AnimatedView
style={[styles.wrapper, {top: this.state.top}]}
>
<View style={styles.container}>
<View style={styles.message}>
<FormattedText
id='mobile.client_upgrade.listener.message'
defaultMessage='A client upgrade is available!'
style={styles.messageText}
/>
</View>
<View style={styles.bottom}>
<TouchableOpacity onPress={this.handleDownload}>
<FormattedText
style={styles.button}
id='mobile.client_upgrade.listener.upgrade_button'
defaultMessage='Upgrade'
/>
</TouchableOpacity>
<TouchableOpacity onPress={this.handleLearnMore}>
<FormattedText
style={styles.button}
id='mobile.client_upgrade.listener.learn_more_button'
defaultMessage='Learn More'
/>
</TouchableOpacity>
{!forceUpgrade &&
<TouchableOpacity onPress={this.handleDismiss}>
<FormattedText
style={styles.button}
id='mobile.client_upgrade.listener.dismiss_button'
defaultMessage='Dismiss'
/>
</TouchableOpacity>
}
</View>
</View>
</AnimatedView>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
bottom: {
flexDirection: 'row',
justifyContent: 'space-around',
borderTopColor: changeOpacity(theme.centerChannelColor, 0.2),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.06),
borderTopWidth: 1
},
button: {
color: theme.linkColor,
fontSize: 13,
paddingHorizontal: 5,
paddingVertical: 5
},
container: {
flex: 1,
backgroundColor: changeOpacity(theme.centerChannelBg, 0.8),
borderRadius: 5
},
message: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
messageText: {
fontSize: 16,
color: changeOpacity(theme.centerChannelColor, 0.8),
fontWeight: '600'
},
wrapper: {
position: 'absolute',
elevation: 5,
left: 30,
right: 30,
height: 75,
backgroundColor: 'white',
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderWidth: 2,
borderRadius: 5,
shadowColor: theme.centerChannelColor,
shadowOffset: {
width: 0,
height: 3
},
shadowOpacity: 0.2,
shadowRadius: 2
}
};
});
export default injectIntl(ClientUpgradeListener);

View File

@@ -1,35 +0,0 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {logError} from 'mattermost-redux/actions/errors';
import {setLastUpgradeCheck} from 'app/actions/views/client_upgrade';
import getClientUpgrade from 'app/selectors/client_upgrade';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import ClientUpgradeListener from './client_upgrade_listener';
function mapStateToProps(state) {
const {currentVersion, downloadLink, forceUpgrade, latestVersion, minVersion} = getClientUpgrade(state);
return {
currentVersion,
downloadLink,
forceUpgrade,
lastUpgradeCheck: state.views.clientUpgrade.lastUpdateCheck,
latestVersion,
minVersion,
theme: getTheme(state)
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
logError,
setLastUpgradeCheck
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ClientUpgradeListener);

View File

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

View File

@@ -3,7 +3,9 @@
import {connect} from 'react-redux';
import {getTeammateNameDisplaySetting, getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getTheme} from 'app/selectors/preferences';
import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import UserListRow from './user_list_row';

View File

@@ -40,7 +40,7 @@ export default class UserListRow extends React.PureComponent {
selected={this.props.selected}
>
<ProfilePicture
userId={this.props.user.id}
user={this.props.user}
size={32}
/>
<View style={style.textContainer}>

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {Keyboard, Dimensions} from 'react-native';
import PropTypes from 'prop-types';
import BaseDrawer from 'react-native-drawer';
@@ -9,49 +8,20 @@ import BaseDrawer from 'react-native-drawer';
export default class Drawer extends BaseDrawer {
static propTypes = {
...BaseDrawer.propTypes,
onRequestClose: PropTypes.func.isRequired,
bottomPanOffset: PropTypes.number,
topPanOffset: PropTypes.number
onRequestClose: PropTypes.func.isRequired
};
constructor(props) {
super(props);
this.keyboardHeight = 0;
}
componentDidMount() {
Keyboard.addListener('keyboardDidShow', this.keyboardDidShow);
Keyboard.addListener('keyboardDidHide', this.keyboardDidHide);
}
componentWillUnmount() {
Keyboard.removeListener('keyboardDidShow', this.keyboardDidShow);
Keyboard.removeListener('keyboardDidHide', this.keyboardDidHide);
}
getMainHeight = () => '100%';
keyboardDidShow = (e) => {
this.keyboardHeight = e.endCoordinates.height;
};
keyboardDidHide = () => {
this.keyboardHeight = 0;
};
isOpened = () => {
return this._open; //eslint-disable-line no-underscore-dangle
};
// To fix the android onLayout issue give this a value of 100% as it does not need another one
getHeight = () => '100%';
processTapGestures = () => {
// Note that we explicitly don't support tap to open or double tap because I didn't copy them over
if (this._activeTween) { //eslint-disable-line no-underscore-dangle
if (this._activeTween) { // eslint-disable-line no-underscore-dangle
return false;
}
if (this.props.tapToClose && this._open) { //eslint-disable-line no-underscore-dangle
if (this.props.tapToClose && this._open) { // eslint-disable-line no-underscore-dangle
this.props.onRequestClose();
return true;
@@ -59,41 +29,4 @@ export default class Drawer extends BaseDrawer {
return false;
};
testPanResponderMask = (e) => {
if (this.props.disabled) {
return false;
}
// Disable if parent or child drawer exist and are open
if (this.context.drawer && this.context.drawer._open) { //eslint-disable-line no-underscore-dangle
return false;
}
if (this._childDrawer && this._childDrawer._open) { //eslint-disable-line no-underscore-dangle
return false;
}
const topPanOffset = this.props.topPanOffset || 0;
const bottomPanOffset = this.props.bottomPanOffset || 0;
const height = Dimensions.get('window').height;
if ((this.props.topPanOffset && e.nativeEvent.pageY < topPanOffset) ||
(this.props.bottomPanOffset && e.nativeEvent.pageY > (height - (bottomPanOffset + this.keyboardHeight)))) {
return false;
}
const pos0 = this.isLeftOrRightSide() ? e.nativeEvent.pageX : e.nativeEvent.pageY;
const deltaOpen = this.isLeftOrTopSide() ? this.getDeviceLength() - pos0 : pos0;
const deltaClose = this.isLeftOrTopSide() ? pos0 : this.getDeviceLength() - pos0;
if (this._open && deltaOpen > this.getOpenMask()) { //eslint-disable-line no-underscore-dangle
return false;
}
if (!this._open && deltaClose > this.getClosedMask()) { //eslint-disable-line no-underscore-dangle
return false;
}
return true;
};
}

View File

@@ -15,8 +15,8 @@ export default class Emoji extends React.PureComponent {
static propTypes = {
customEmojis: PropTypes.object,
emojiName: PropTypes.string.isRequired,
fontSize: PropTypes.number,
literal: PropTypes.string,
padding: PropTypes.number,
size: PropTypes.number.isRequired,
textStyle: CustomPropTypes.Style,
token: PropTypes.string.isRequired
@@ -24,138 +24,49 @@ export default class Emoji extends React.PureComponent {
static defaultProps = {
customEmojis: new Map(),
literal: ''
literal: '',
padding: 10
};
constructor(props) {
super(props);
this.state = {
...this.getImageUrl(props),
originalWidth: 0,
originalHeight: 0
};
}
componentWillMount() {
this.mounted = true;
if (this.state.imageUrl && this.state.isCustomEmoji) {
this.updateImageHeight(this.state.imageUrl);
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.customEmojis !== this.props.customEmojis || nextProps.emojiName !== this.props.emojiName) {
this.setState({
...this.getImageUrl(nextProps),
originalWidth: 0,
originalHeight: 0
});
}
}
componentWillUpdate(nextProps, nextState) {
if (nextState.imageUrl !== this.state.imageUrl && nextState.imageUrl && nextState.isCustomEmoji) {
this.updateImageHeight(nextState.imageUrl);
}
}
componentWillUnmount() {
this.mounted = false;
}
getImageUrl = (props = this.props) => {
const emojiName = props.emojiName;
let imageUrl = '';
let isCustomEmoji = false;
if (EmojiIndicesByAlias.has(emojiName)) {
const emoji = Emojis[EmojiIndicesByAlias.get(emojiName)];
imageUrl = Client4.getSystemEmojiImageUrl(emoji.filename);
} else if (props.customEmojis.has(emojiName)) {
const emoji = props.customEmojis.get(emojiName);
imageUrl = Client4.getCustomEmojiImageUrl(emoji.id);
isCustomEmoji = true;
}
return {
imageUrl,
isCustomEmoji
};
}
updateImageHeight = (imageUrl) => {
Image.getSize(imageUrl, (originalWidth, originalHeight) => {
if (this.mounted) {
this.setState({
originalWidth,
originalHeight
});
}
});
}
render() {
const {
fontSize,
customEmojis,
emojiName,
literal,
padding,
size,
textStyle,
token
} = this.props;
if (!this.state.imageUrl) {
let imageUrl;
if (EmojiIndicesByAlias.has(emojiName)) {
const emoji = Emojis[EmojiIndicesByAlias.get(emojiName)];
imageUrl = Client4.getSystemEmojiImageUrl(emoji.filename);
} else if (customEmojis.has(emojiName)) {
const emoji = customEmojis.get(emojiName);
imageUrl = Client4.getCustomEmojiImageUrl(emoji.id);
}
if (!imageUrl) {
return <Text style={textStyle}>{literal}</Text>;
}
let ImageComponent;
if (Platform.OS === 'android') {
ImageComponent = Image;
} else {
ImageComponent = FastImage;
}
let ImageComponent = FastImage;
const source = {
uri: this.state.imageUrl,
uri: imageUrl,
headers: {
Authorization: `Bearer ${token}`
}
};
let width = size;
let height = size;
if (this.state.originalHeight && this.state.originalWidth) {
if (this.state.originalWidth > this.state.originalHeight) {
height = (size * this.state.originalHeight) / this.state.originalWidth;
} else if (this.state.originalWidth < this.state.originalHeight) {
// This may cause text to reflow, but its impossible to add a horizontal margin
width = (size * this.state.originalWidth) / this.state.originalHeight;
}
if (Platform.OS === 'android') {
ImageComponent = Image;
}
let marginTop = 0;
if (fontSize) {
// Center the image vertically on iOS (does nothing on Android)
marginTop = (height - fontSize) / 2;
// hack to get the vertical alignment looking better
if (fontSize === 17) {
marginTop -= 2;
} else if (fontSize === 15) {
marginTop += 1;
}
}
// Android can't change the size of an image after its first render, so
// force a new image to be rendered when the size changes
const key = Platform.OS === 'android' ? (height + '-' + width) : null;
return (
<ImageComponent
key={key}
style={{width, height, marginTop}}
style={{width: size, height: size, padding}}
source={source}
onError={this.onError}
/>

View File

@@ -5,6 +5,7 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {injectIntl, intlShape} from 'react-intl';
import {
Dimensions,
SectionList,
TouchableOpacity,
View
@@ -16,6 +17,7 @@ import SearchBar from 'app/components/search_bar';
import {emptyFunction} from 'app/utils/general';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
const {width: deviceWidth} = Dimensions.get('window');
const EMOJI_SIZE = 30;
const EMOJI_GUTTER = 7.5;
const SECTION_MARGIN = 15;
@@ -23,7 +25,6 @@ const SECTION_MARGIN = 15;
class EmojiPicker extends PureComponent {
static propTypes = {
emojis: PropTypes.array.isRequired,
deviceWidth: PropTypes.number.isRequired,
intl: intlShape.isRequired,
onEmojiPress: PropTypes.func,
theme: PropTypes.object.isRequired
@@ -42,19 +43,10 @@ class EmojiPicker extends PureComponent {
this.state = {
emojis: props.emojis,
searchTerm: '',
width: props.deviceWidth - (SECTION_MARGIN * 2)
searchTerm: ''
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.deviceWidth !== this.props.deviceWidth) {
this.setState({
width: nextProps.deviceWidth - (SECTION_MARGIN * 2)
});
}
}
changeSearchTerm = (text) => {
this.setState({
searchTerm: text
@@ -75,11 +67,11 @@ class EmojiPicker extends PureComponent {
emojis: this.props.emojis,
searchTerm: ''
});
};
}
filterEmojiAliases = (aliases, searchTerm) => {
return aliases.findIndex((alias) => alias.includes(searchTerm)) !== -1;
};
}
searchEmojis = (searchTerm) => {
const {emojis} = this.props;
@@ -114,7 +106,7 @@ class EmojiPicker extends PureComponent {
});
return nextEmojis;
};
}
renderSectionHeader = ({section}) => {
const {theme} = this.props;
@@ -129,7 +121,7 @@ class EmojiPicker extends PureComponent {
/>
</View>
);
};
}
renderEmojis = (emojis, index) => {
const {theme} = this.props;
@@ -165,13 +157,13 @@ class EmojiPicker extends PureComponent {
})}
</View>
);
};
}
renderItem = ({item}) => {
const {theme} = this.props;
const styles = getStyleSheetFromTheme(theme);
const numColumns = Number((this.state.width / (EMOJI_SIZE + (EMOJI_GUTTER * 2))).toFixed(0));
const numColumns = Number(((deviceWidth - (SECTION_MARGIN * 2)) / (EMOJI_SIZE + (EMOJI_GUTTER * 2))).toFixed(0));
const slices = item.items.reduce((slice, emoji, emojiIndex) => {
if (emojiIndex % numColumns === 0 && emojiIndex !== 0) {
@@ -188,7 +180,7 @@ class EmojiPicker extends PureComponent {
{slices.map(this.renderEmojis)}
</View>
);
};
}
render() {
const {intl, theme} = this.props;
@@ -261,7 +253,8 @@ const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme) => {
marginRight: 0
},
listView: {
backgroundColor: theme.centerChannelBg
backgroundColor: theme.centerChannelBg,
width: deviceWidth - (SECTION_MARGIN * 2)
},
searchBar: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),

View File

@@ -6,8 +6,7 @@ import {createSelector} from 'reselect';
import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
import {getDimensions} from 'app/selectors/device';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getTheme} from 'app/selectors/preferences';
import {CategoryNames, Emojis, EmojiIndicesByCategory} from 'app/utils/emojis';
import EmojiPicker from './emoji_picker';
@@ -99,11 +98,9 @@ const getEmojisBySection = createSelector(
function mapStateToProps(state) {
const emojis = getEmojisBySection(state);
const {deviceWidth} = getDimensions(state);
return {
emojis,
deviceWidth,
theme: getTheme(state)
};
}

View File

@@ -7,14 +7,19 @@ import PropTypes from 'prop-types';
import {Text} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getTheme} from 'app/selectors/preferences';
import {GlobalStyles} from 'app/styles';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
class ErrorText extends PureComponent {
static propTypes = {
error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
theme: PropTypes.object.isRequired
theme: PropTypes.object
};
static defaultProps = {
error: {},
theme: {}
};
render() {

View File

@@ -64,7 +64,7 @@ export default class FileAttachment extends PureComponent {
const style = getStyleSheet(theme);
let fileAttachmentComponent;
if (file.has_preview_image || file.loading || file.mime_type === 'image/gif') {
if (file.has_preview_image || file.loading) {
fileAttachmentComponent = (
<FileAttachmentImage
addFileToFetchCache={this.props.addFileToFetchCache}

View File

@@ -7,7 +7,7 @@ import {connect} from 'react-redux';
import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files';
import {loadFilesForPostIfNecessary} from 'app/actions/views/channel';
import {addFileToFetchCache} from 'app/actions/views/file_preview';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getTheme} from 'app/selectors/preferences';
import FileAttachmentList from './file_attachment_list';

View File

@@ -4,6 +4,7 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Dimensions,
Platform,
ScrollView,
StyleSheet,
@@ -16,6 +17,8 @@ import FileAttachmentImage from 'app/components/file_attachment_list/file_attach
import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachment_icon';
import KeyboardLayout from 'app/components/layout/keyboard_layout';
const {height: deviceHeight} = Dimensions.get('window');
export default class FileUploadPreview extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
@@ -26,7 +29,6 @@ export default class FileUploadPreview extends PureComponent {
channelId: PropTypes.string.isRequired,
channelIsLoading: PropTypes.bool,
createPostRequestStatus: PropTypes.string.isRequired,
deviceHeight: PropTypes.number.isRequired,
fetchCache: PropTypes.object.isRequired,
files: PropTypes.array.isRequired,
inputHeight: PropTypes.number.isRequired,
@@ -96,7 +98,7 @@ export default class FileUploadPreview extends PureComponent {
</View>
);
});
};
}
render() {
if (this.props.channelIsLoading || (!this.props.files.length && !this.props.filesUploadingForCurrentChannel)) {
@@ -105,7 +107,7 @@ export default class FileUploadPreview extends PureComponent {
return (
<KeyboardLayout>
<View style={[style.container, {height: this.props.deviceHeight}]}>
<View style={[style.container]}>
<ScrollView
horizontal={true}
style={style.scrollView}
@@ -122,6 +124,7 @@ export default class FileUploadPreview extends PureComponent {
const style = StyleSheet.create({
container: {
backgroundColor: 'rgba(0, 0, 0, 0.5)',
height: deviceHeight,
left: 0,
bottom: 0,
position: 'absolute',

View File

@@ -7,8 +7,7 @@ import {createSelector} from 'reselect';
import {handleRemoveFile, retryFileUpload} from 'app/actions/views/file_upload';
import {addFileToFetchCache} from 'app/actions/views/file_preview';
import {getDimensions} from 'app/selectors/device';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getTheme} from 'app/selectors/preferences';
import FileUploadPreview from './file_upload_preview';
@@ -21,22 +20,15 @@ const checkForFileUploadingInChannel = createSelector(
return state.views.channel.drafts[channelId];
},
(draft) => {
if (!draft || !draft.files) {
return false;
}
return draft.files.some((f) => f.loading);
}
);
function mapStateToProps(state, ownProps) {
const {deviceHeight} = getDimensions(state);
return {
...ownProps,
channelIsLoading: state.views.channel.loading,
createPostRequestStatus: state.requests.posts.createPost.status,
deviceHeight,
fetchCache: state.views.fetchCache,
filesUploadingForCurrentChannel: checkForFileUploadingInChannel(state, ownProps.channelId, ownProps.rootId),
theme: getTheme(state)

View File

@@ -1,12 +1,12 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {injectIntl, intlShape} from 'react-intl';
import {Text} from 'react-native';
class FormattedDate extends React.PureComponent {
class FormattedDate extends Component {
static propTypes = {
intl: intlShape.isRequired,
value: PropTypes.any.isRequired,

View File

@@ -1,12 +1,12 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {createElement, isValidElement} from 'react';
import {Component, createElement, isValidElement} from 'react';
import PropTypes from 'prop-types';
import {Text} from 'react-native';
import {injectIntl, intlShape} from 'react-intl';
class FormattedText extends React.PureComponent {
class FormattedText extends Component {
static propTypes = {
intl: intlShape.isRequired,
id: PropTypes.string.isRequired,
@@ -80,9 +80,9 @@ class FormattedText extends React.PureComponent {
// approach allows messages to render with React Elements while
// keeping React's virtual diffing working properly.
nodes = formattedMessage.
split(tokenDelimiter).
filter((part) => Boolean(part)).
map((part) => elements[part] || part);
split(tokenDelimiter).
filter((part) => Boolean(part)).
map((part) => elements[part] || part);
} else {
nodes = [formattedMessage];
}

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