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
255 changed files with 5618 additions and 14985 deletions

5
.gitignore vendored
View File

@@ -65,11 +65,6 @@ 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,708 +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.graphics.BitmapFactory;
import android.net.Uri;
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 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()
{
responseHelper.invokeCancel(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.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);
cameraCaptureURI = RealPathUtil.compatUriFromFile(reactContext, imageConfig.original);
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;
}
this.callback = callback;
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;
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;
}
this.callback = callback;
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);
}
});
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");
}
}
}

148
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,77 +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
npm run run-android
test: pre-run
@yarn test
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_SENSOR);|g' node_modules/react-native-navigation/android/app/src/main/java/com/reactnativenavigation/params/Orientation.java
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 \
@@ -135,20 +117,11 @@ 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
@@ -160,14 +133,8 @@ do-build-ios:
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
@@ -175,6 +142,7 @@ 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"
@@ -182,24 +150,6 @@ do-build-android:
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.
@@ -148,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 57
versionName "1.3.0"
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 ->
@@ -170,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,28 +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 java.util.LinkedHashMap;
import java.util.ArrayList;
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 {
@@ -36,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,ArrayList<Bundle>> channelIdToNotification = new LinkedHashMap<String,ArrayList<Bundle>>();
private static AppLifecycleFacade lifecycleFacade;
private static Context context;
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper);
this.context = context;
}
public static void clearNotification(int notificationId, String channelId) {
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);
}
}
@@ -76,11 +60,11 @@ public class CustomPushNotification extends PushNotification {
Object bundleArray = channelIdToNotification.get(channelId);
ArrayList list = null;
if (bundleArray == null) {
list = new ArrayList(0);
list = new ArrayList();
} else {
list = (ArrayList)bundleArray;
}
list.add(0, data);
list.add(data);
channelIdToNotification.put(channelId, list);
}
@@ -105,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);
}
}
@@ -120,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) {
@@ -131,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;
@@ -172,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
@@ -193,52 +166,26 @@ public class CustomPushNotification extends PushNotification {
Notification.InboxStyle style = new Notification.InboxStyle();
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);
@@ -248,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 = RingtoneManager.getActualDefaultRingtoneUri(mContext, RingtoneManager.TYPE_NOTIFICATION);
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;
}
@@ -276,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

@@ -6,10 +6,6 @@ import android.content.Context;
import android.os.Bundle;
import com.facebook.react.ReactApplication;
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;
@@ -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,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'

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

@@ -10,12 +10,11 @@ import {
fetchMyChannelsAndMembers,
getChannelStats,
selectChannel,
leaveChannel as serviceLeaveChannel,
unfavoriteChannel
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';
@@ -31,8 +30,6 @@ import {
import {getLastCreateAt} from 'mattermost-redux/utils/post_utils';
import {getPreferencesByCategory} from 'mattermost-redux/utils/preference_utils';
const MAX_POST_TRIES = 3;
export function loadChannelsIfNecessary(teamId) {
return async (dispatch, getState) => {
await fetchMyChannelsAndMembers(teamId)(dispatch, getState);
@@ -42,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;
@@ -99,8 +96,7 @@ export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
}
for (const [key, pref] of gmPrefs) {
//only load the profiles in channels if we don't already have them
if (pref.value === 'true' && !profilesInChannel[key]) {
if (pref.value === 'true') {
loadProfilesForChannels.push(key);
}
}
@@ -141,57 +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);
};
}
async function retryGetPostsAction(action, dispatch, getState, maxTries = MAX_POST_TRIES) {
for (let i = 0; i < maxTries; i++) {
const posts = await action(dispatch, getState);
if (posts) {
return posts;
}
}
return null;
}
export function loadFilesForPostIfNecessary(postId) {
return async (dispatch, getState) => {
const {files} = getState().entities;
@@ -218,35 +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 lastChannelId = state.views.team.lastChannelForTeam[teamId] || '';
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);
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);
if (channel) {
dispatch(setChannelDisplayName(''));
handleSelectChannel(channel.id)(dispatch, getState);
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: ''};
dispatch(setChannelDisplayName(''));
handleSelectChannel(firstChannel.id)(dispatch, getState);
await handleSelectChannel(firstChannel.id)(dispatch, getState);
}
};
}
@@ -255,15 +218,13 @@ export function handleSelectChannel(channelId) {
return async (dispatch, getState) => {
const {currentTeamId} = getState().entities.teams;
selectChannel(channelId)(dispatch, getState);
dispatch(setChannelLoading(false));
dispatch({
type: ViewTypes.SET_LAST_CHANNEL_FOR_TEAM,
teamId: currentTeamId,
channelId
});
getChannelStats(channelId)(dispatch, getState);
selectChannel(channelId)(dispatch, getState);
};
}
@@ -314,7 +275,7 @@ export function closeDMChannel(channel) {
const state = getState();
if (channel.isFavorite) {
unfavoriteChannel(channel.id)(dispatch, getState);
unmarkFavorite(channel.id)(dispatch, getState);
}
toggleDMChannel(channel.teammate_id, 'false')(dispatch, getState);
@@ -329,7 +290,7 @@ export function closeGMChannel(channel) {
const state = getState();
if (channel.isFavorite) {
unfavoriteChannel(channel.id)(dispatch, getState);
unmarkFavorite(channel.id)(dispatch, getState);
}
toggleGMChannel(channel.id, 'false')(dispatch, getState);
@@ -339,9 +300,36 @@ export function closeGMChannel(channel) {
};
}
export function markFavorite(channelId) {
return async (dispatch, getState) => {
const {currentUserId} = getState().entities.users;
const fav = [{
user_id: currentUserId,
category: Preferences.CATEGORY_FAVORITE_CHANNEL,
name: channelId,
value: 'true'
}];
savePreferences(currentUserId, fav)(dispatch, getState);
};
}
export function unmarkFavorite(channelId) {
return async (dispatch, getState) => {
const {currentUserId} = getState().entities.users;
const fav = [{
user_id: currentUserId,
category: Preferences.CATEGORY_FAVORITE_CHANNEL,
name: channelId
}];
deletePreferences(currentUserId, fav)(dispatch, getState);
};
}
export function refreshChannelWithRetry(channelId) {
return (dispatch, getState) => {
return retryGetPostsAction(getPosts(channelId), dispatch, getState);
getPostsWithRetry(channelId)(dispatch, getState);
};
}
@@ -362,13 +350,6 @@ export function setChannelLoading(loading = true) {
};
}
export function setChannelRefreshing(loading = true) {
return {
type: ViewTypes.SET_CHANNEL_REFRESHING,
loading
};
}
export function setPostTooltipVisible(visible = true) {
return {
type: ViewTypes.POST_TOOLTIP_VISIBLE,

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

@@ -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 {getPostsInCurrentChannel, makeGetPostsForThread} from 'mattermost-redux/selectors/entities/posts';
const getPostsForThread = makeGetPostsForThread();
export function addReactionToLatestPost(emoji, rootId) {
return async (dispatch, getState) => {
const state = getState();
const posts = rootId ? getPostsForThread(state, {rootId}) : getPostsInCurrentChannel(state);
const lastPost = posts[0];
dispatch(addReaction(lastPost.id, emoji));
};
}

View File

@@ -4,13 +4,15 @@
import {ViewTypes} from 'app/constants';
import {
handleSelectChannel,
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 {getChannelAndMyMember, markChannelAsRead, viewChannel} from 'mattermost-redux/actions/channels';
import {markChannelAsRead, viewChannel} from 'mattermost-redux/actions/channels';
export function loadConfigAndLicense() {
return async (dispatch, getState) => {
@@ -40,34 +42,39 @@ export function goToNotification(notification) {
const state = getState();
const {data} = notification;
const {currentTeamId, teams} = state.entities.teams;
const {channels, currentChannelId, myMembers} = state.entities.channels;
const {currentChannelId} = state.entities.channels;
const channelId = data.channel_id;
// if the notification does not have a team id is because its from a DM or GM
const teamId = data.team_id || currentTeamId;
let teamId = data.team_id || currentTeamId;
dispatch(setChannelDisplayName(''));
if (teamId && teamId !== currentTeamId) {
if (teamId) {
handleTeamChange(teams[teamId], false)(dispatch, getState);
} else if (!teamId) {
await loadChannelsIfNecessary(teamId)(dispatch, getState);
} else {
await selectFirstAvailableTeam()(dispatch, getState);
teamId = state.entities.team.currentTeamId;
}
if (!channels[channelId] || !myMembers[channelId]) {
getChannelAndMyMember(channelId)(dispatch, getState);
}
viewChannel(channelId)(dispatch, getState);
loadProfilesAndTeamMembersForDMSidebar(teamId)(dispatch, getState);
if (channelId !== currentChannelId) {
handleSelectChannel(channelId)(dispatch, getState);
}
viewChannel(channelId)(dispatch, getState);
markChannelAsRead(channelId, currentChannelId)(dispatch, getState);
};
}
export function setStatusBarHeight(height = 20) {
return {
type: ViewTypes.STATUSBAR_HEIGHT_CHANGED,
data: height
};
}
export function purgeOfflineStore() {
return {type: General.OFFLINE_STORE_PURGE};
}
@@ -77,5 +84,5 @@ export default {
queueNotification,
clearNotification,
goToNotification,
purgeOfflineStore
setStatusBarHeight
};

View File

@@ -3,10 +3,8 @@
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';
@@ -14,24 +12,20 @@ import {setChannelDisplayName} from './channel';
export function handleTeamChange(team, selectChannel = true) {
return async (dispatch, getState) => {
const state = getState();
const {currentTeamId} = state.entities.teams;
const {currentTeamId} = getState().entities.teams;
if (currentTeamId === team.id) {
return;
}
const state = getState();
const actions = [
setChannelDisplayName(''),
{type: TeamTypes.SELECT_TEAM, data: team.id}
];
if (selectChannel) {
actions.push({type: ChannelTypes.SELECT_CHANNEL, data: ''});
const lastChannelId = state.views.team.lastChannelForTeam[team.id] || '';
const currentChannelId = getCurrentChannelId(state);
viewChannel(lastChannelId, currentChannelId)(dispatch, getState);
markChannelAsRead(lastChannelId, currentChannelId)(dispatch, getState);
actions.push({type: ChannelTypes.SELECT_CHANNEL, data: lastChannelId});
}
dispatch(batchActions(actions), getState);

View File

@@ -1,39 +1,47 @@
// 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
theme: PropTypes.object.isRequired,
onChangeText: PropTypes.func.isRequired,
actions: PropTypes.shape({
autocompleteUsers: PropTypes.func.isRequired
})
};
static defaultProps = {
autocompleteUsers: {},
defaultChannel: {},
postDraft: '',
isSearch: false
@@ -42,82 +50,74 @@ export default class AtMention extends PureComponent {
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 = () => {
@@ -149,7 +149,7 @@ export default class AtMention extends PureComponent {
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} `);
}
@@ -158,63 +158,141 @@ export default class AtMention extends PureComponent {
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,29 +4,21 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
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 'app/selectors/preferences';
import {autocompleteUsers} from 'mattermost-redux/actions/users';
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, rootId} = ownProps;
const currentChannelId = getCurrentChannelId(state);
const {currentChannelId} = state.entities.channels;
let postDraft = '';
if (isSearch) {
let postDraft;
if (ownProps.isSearch) {
postDraft = state.views.search;
} else if (ownProps.rootId) {
const threadDraft = state.views.thread.drafts[rootId];
const threadDraft = state.views.thread.drafts[ownProps.rootId];
if (threadDraft) {
postDraft = threadDraft.draft;
}
@@ -37,28 +29,16 @@ function mapStateToProps(state, ownProps) {
}
}
const value = postDraft.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);
}
return {
currentUserId: state.entities.users.currentUserId,
currentChannelId,
currentTeamId: getCurrentTeamId(state),
currentTeamId: state.entities.teams.currentTeamId,
defaultChannel: getDefaultChannel(state),
postDraft,
matchTerm,
teamMembers,
inChannel,
outChannel,
autocompleteUsers: {
inChannel: getProfilesInCurrentChannel(state),
outChannel: getProfilesNotInCurrentChannel(state)
},
requestStatus: state.requests.users.autocompleteUsers.status,
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 'app/selectors/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,34 +1,37 @@
// 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,
isSearch: PropTypes.bool,
matchTerm: PropTypes.string,
myChannels: PropTypes.array,
otherChannels: PropTypes.array,
onChangeText: PropTypes.func.isRequired,
autocompleteChannels: PropTypes.object.isRequired,
postDraft: PropTypes.string,
privateChannels: PropTypes.array,
publicChannels: PropTypes.array,
isSearch: PropTypes.bool,
requestStatus: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired
theme: PropTypes.object.isRequired,
onChangeText: PropTypes.func.isRequired,
actions: PropTypes.shape({
searchChannels: PropTypes.func.isRequired
})
};
static defaultProps = {
@@ -39,82 +42,97 @@ export default class ChannelMention extends PureComponent {
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, postDraft} = this.props;
const mentionPart = postDraft.substring(0, cursorPosition);
@@ -122,7 +140,7 @@ export default class ChannelMention extends PureComponent {
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} `);
}
@@ -131,54 +149,88 @@ export default class ChannelMention extends PureComponent {
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,29 +5,21 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {searchChannels} from 'mattermost-redux/actions/channels';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/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 'app/selectors/preferences';
import ChannelMention from './channel_mention';
function mapStateToProps(state, ownProps) {
const {cursorPosition, isSearch, rootId} = ownProps;
const currentChannelId = getCurrentChannelId(state);
const {currentChannelId} = state.entities.channels;
let postDraft = '';
if (isSearch) {
let postDraft;
if (ownProps.isSearch) {
postDraft = state.views.search;
} else if (rootId) {
const threadDraft = state.views.thread.drafts[rootId];
} else if (ownProps.rootId) {
const threadDraft = state.views.thread.drafts[ownProps.rootId];
if (threadDraft) {
postDraft = threadDraft.draft;
}
@@ -38,30 +30,17 @@ function mapStateToProps(state, ownProps) {
}
}
const value = postDraft.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);
}
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 'app/selectors/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,19 +13,15 @@ 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
onChangeText: PropTypes.func.isRequired
};
static defaultProps = {
@@ -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, postDraft, rootId} = this.props;
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 (postDraft.length > cursorPosition) {
completedDraft += postDraft.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,11 +3,9 @@
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 'app/selectors/preferences';
import {EmojiIndicesByAlias} from 'app/utils/emojis';
@@ -49,12 +47,4 @@ function mapStateToProps(state, ownProps) {
};
}
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,7 +12,27 @@ 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,
@@ -35,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}
@@ -57,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

@@ -7,7 +7,6 @@ import {
BackHandler,
InteractionManager,
Keyboard,
StyleSheet,
View
} from 'react-native';
@@ -15,14 +14,13 @@ 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 PureComponent {
static propTypes = {
@@ -37,34 +35,30 @@ export default class ChannelDrawer extends PureComponent {
}).isRequired,
blurPostTextBox: PropTypes.func.isRequired,
children: PropTypes.node,
currentChannelId: PropTypes.string.isRequired,
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 = {
openDrawer: false,
openDrawerOffset
};
}
componentWillMount() {
this.props.actions.getTeams();
}
@@ -72,30 +66,13 @@ export default class ChannelDrawer extends PureComponent {
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);
this.mounted = true;
}
componentWillReceiveProps(nextProps) {
const {isLandscape, isTablet} = this.props;
if (nextProps.isLandscape !== isLandscape || nextProps.isTablet || isTablet) {
if (this.state.openDrawerOffset !== 0) {
let openDrawerOffset = DRAWER_INITIAL_OFFSET;
if (nextProps.isLandscape || nextProps.isTablet) {
openDrawerOffset = DRAWER_LANDSCAPE_OFFSET;
}
this.setState({openDrawerOffset});
}
}
}
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);
this.mounted = false;
}
handleAndroidBack = () => {
@@ -108,13 +85,7 @@ export default class ChannelDrawer extends PureComponent {
};
closeChannelDrawer = () => {
if (this.mounted) {
this.setState({openDrawer: false});
}
};
drawerSwiperRef = (ref) => {
this.drawerSwiper = ref;
this.setState({openDrawer: false});
};
handleDrawerClose = () => {
@@ -125,11 +96,8 @@ export default class ChannelDrawer extends PureComponent {
this.closeLeftHandle = null;
}
if (this.state.openDrawer && this.mounted) {
// The state doesn't get updated if you swipe to close
this.setState({
openDrawer: false
});
if (this.state.openDrawer) {
this.setState({openDrawer: false});
}
};
@@ -140,7 +108,8 @@ export default class ChannelDrawer extends PureComponent {
};
handleDrawerOpen = () => {
if (this.state.openDrawerOffset !== 0) {
this.setState({openDrawer: true});
if (this.state.openDrawerOffset === DRAWER_INITIAL_OFFSET) {
Keyboard.dismiss();
}
@@ -154,13 +123,6 @@ export default class ChannelDrawer extends PureComponent {
if (!this.openLeftHandle) {
this.openLeftHandle = InteractionManager.createInteractionHandle();
}
if (!this.state.openDrawer && this.mounted) {
// The state doesn't get updated if you swipe to open
this.setState({
openDrawer: true
});
}
};
handleDrawerTween = (ratio) => {
@@ -181,28 +143,15 @@ export default class ChannelDrawer extends PureComponent {
};
};
handleUpdateTitle = (channel) => {
let channelName = '';
if (channel.display_name) {
channelName = channel.display_name;
}
this.props.actions.setChannelDisplayName(channelName);
};
openChannelDrawer = () => {
this.props.blurPostTextBox();
if (this.mounted) {
this.setState({
openDrawer: true
});
}
this.setState({openDrawer: true});
};
selectChannel = (channel) => {
const {
actions,
currentChannelId
currentChannel
} = this.props;
const {
@@ -213,63 +162,72 @@ export default class ChannelDrawer extends PureComponent {
viewChannel
} = actions;
markChannelAsRead(channel.id, currentChannel.id);
setChannelLoading();
viewChannel(currentChannel.id);
setChannelDisplayName(channel.display_name);
this.closeChannelDrawer();
InteractionManager.runAfterInteractions(() => {
handleSelectChannel(channel.id);
markChannelAsRead(channel.id, currentChannelId);
if (channel.id !== currentChannelId) {
viewChannel(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);
};
onPageSelected = (index) => {
@@ -278,13 +236,8 @@ export default class ChannelDrawer extends PureComponent {
onSearchEnds = () => {
//hack to update the drawer when the offset changes
const {isLandscape, isTablet} = this.props;
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.setState({openDrawerOffset: DRAWER_INITIAL_OFFSET});
};
onSearchStart = () => {
@@ -293,63 +246,56 @@ export default class ChannelDrawer extends PureComponent {
};
showTeams = () => {
if (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.swiperIndex !== 1) {
this.drawerSwiper.getWrappedInstance().resetPage();
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}
@@ -358,15 +304,16 @@ export default class ChannelDrawer extends PureComponent {
);
return (
<DrawerSwiper
ref={this.drawerSwiperRef}
<Swiper
ref='swiper'
onPageSelected={this.onPageSelected}
openDrawerOffset={openDrawerOffset}
showTeams={showTeams}
theme={theme}
>
{lists}
</DrawerSwiper>
{teams}
{channelsList}
</Swiper>
);
};
@@ -380,12 +327,11 @@ export default class ChannelDrawer extends PureComponent {
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}
@@ -417,10 +363,3 @@ export default class ChannelDrawer extends PureComponent {
);
}
}
const style = StyleSheet.create({
swiperContent: {
flex: 1,
marginBottom: 10
}
});

View File

@@ -26,9 +26,9 @@ export default class ChannelItem extends PureComponent {
onPress = () => {
const {channel, onSelectChannel} = this.props;
requestAnimationFrame(() => {
setTimeout(() => {
preventDoubleTap(onSelectChannel, this, channel);
});
}, 100);
};
render() {

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 SwitchTeams from './switch_teams';
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) => {
if (channel.fake) {
this.props.onJoinChannel(channel);
} else {
this.props.onSelectChannel(channel);
}
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 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>
);
return (
<View
style={styles.container}
>
<View style={styles.statusBar}>
<View style={styles.headerContainer}>
<SwitchTeams
searching={searching}
showTeams={onShowTeams}
/>
{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
},
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
},
indicatorText: {
backgroundColor: 'transparent',
color: theme.mentionColor,
fontSize: 14,
paddingVertical: 2,
paddingHorizontal: 4,
textAlign: 'center',
textAlignVertical: 'center'
}
};
});
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,
@@ -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,17 +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 'app/selectors/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, ownProps) {
return {
...ownProps,
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,9 +4,7 @@
import {connect} from 'react-redux';
import {General} from 'mattermost-redux/constants';
import {getChannelsWithUnreadSection, getCurrentChannel, getMyChannelMemberships} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId, getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {showCreateOption} from 'mattermost-redux/utils/channel_utils';
import {isAdmin, isSystemAdmin} from 'mattermost-redux/utils/user_utils';
@@ -18,10 +16,6 @@ function mapStateToProps(state, ownProps) {
return {
canCreatePrivateChannels: showCreateOption(config, license, General.PRIVATE_CHANNEL, isAdmin(roles), isSystemAdmin(roles)),
channelMembers: getMyChannelMemberships(state),
channels: getChannelsWithUnreadSection(state),
currentChannel: getCurrentChannel(state),
theme: getTheme(state),
...ownProps
};
}

View File

@@ -14,7 +14,7 @@ import {injectIntl, intlShape} from 'react-intl';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import FormattedText from 'app/components/formatted_text';
import {wrapWithPreventDoubleTap} from 'app/utils/tap';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity} from 'app/utils/theme';
import {General} from 'mattermost-redux/constants';
@@ -151,7 +151,7 @@ class List extends Component {
);
};
createPrivateChannel = wrapWithPreventDoubleTap(() => {
createPrivateChannel = () => {
const {intl, navigator, theme} = this.props;
navigator.showModal({
@@ -171,7 +171,7 @@ class List extends Component {
closeButton: this.closeButton
}
});
});
};
buildChannels = (props) => {
const {canCreatePrivateChannels, styles} = props;
@@ -233,15 +233,7 @@ class List extends Component {
return data;
};
scrollToTop = () => {
this.refs.list.scrollToOffset({
x: 0,
y: 0,
animated: true
});
}
showDirectMessagesModal = wrapWithPreventDoubleTap(() => {
showDirectMessagesModal = () => {
const {intl, navigator, theme} = this.props;
navigator.showModal({
@@ -263,9 +255,9 @@ class List extends Component {
}]
}
});
});
};
showMoreChannelsModal = wrapWithPreventDoubleTap(() => {
showMoreChannelsModal = () => {
const {intl, navigator, theme} = this.props;
navigator.showModal({
@@ -284,14 +276,14 @@ class List extends Component {
closeButton: this.closeButton
}
});
});
};
renderSectionAction = (styles, action) => {
const {theme} = this.props;
return (
<TouchableHighlight
style={styles.actionContainer}
onPress={action}
onPress={() => preventDoubleTap(action, this)}
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
>
<MaterialIcon
@@ -348,7 +340,7 @@ class List extends Component {
above = (
<UnreadIndicator
style={[styles.above, {width: (this.width - 40)}]}
onPress={this.scrollToTop}
onPress={() => this.refs.list.scrollToOffset({x: 0, y: 0, animated: true})}
text={(
<FormattedText
style={styles.indicatorText}

View File

@@ -1,20 +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, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams';
import SwitchTeams from './switch_teams';
function mapStateToProps(state, ownProps) {
return {
currentTeam: getCurrentTeam(state),
teamMembers: getTeamMemberships(state),
theme: getTheme(state),
...ownProps
};
}
export default connect(mapStateToProps)(SwitchTeams);

View File

@@ -1,172 +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 SwitchTeams extends React.PureComponent {
static propTypes = {
currentTeam: PropTypes.object,
searching: PropTypes.bool.isRequired,
showTeams: PropTypes.func.isRequired,
teamMembers: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired
};
constructor(props) {
super(props);
this.state = {
badgeCount: this.getBadgeCount(props)
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.currentTeam !== this.props.currentTeam || nextProps.teamMembers !== this.props.teamMembers) {
this.setState({
badgeCount: this.getBadgeCount(nextProps)
});
}
}
getBadgeCount = (props) => {
const {
currentTeam,
teamMembers
} = props;
let mentionCount = 0;
let messageCount = 0;
Object.values(teamMembers).forEach((m) => {
if (m.team_id !== currentTeam.id) {
mentionCount = mentionCount + (m.mention_count || 0);
messageCount = messageCount + (m.msg_count || 0);
}
});
let badgeCount;
if (mentionCount) {
badgeCount = mentionCount;
} else if (messageCount) {
badgeCount = -1;
} else {
badgeCount = 0;
}
return badgeCount;
};
showTeams = wrapWithPreventDoubleTap(() => {
this.props.showTeams();
});
render() {
const {
currentTeam,
searching,
teamMembers,
theme
} = this.props;
if (!currentTeam) {
return null;
}
const {
badgeCount
} = this.state;
if (searching || Object.keys(teamMembers).length < 2) {
return null;
}
const styles = getStyleSheet(theme);
let badge;
if (badgeCount) {
badge = (
<Badge
style={styles.badge}
countStyle={styles.mention}
count={badgeCount}
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}>
{currentTeam.display_name.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: 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: -5
},
mention: {
color: theme.mentionColor,
fontSize: 10
}
};
});

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 {getDimensions, isLandscape} from 'app/selectors/device';
import DraweSwiper from './drawer_swiper';
function mapStateToProps(state, ownProps) {
return {
...ownProps,
...getDimensions(state),
isLandscape: isLandscape(state)
};
}
export default connect(mapStateToProps, null, null, {withRef: true})(DraweSwiper);

View File

@@ -6,12 +6,11 @@ import {connect} from 'react-redux';
import {joinChannel, viewChannel, markChannelAsRead} from 'mattermost-redux/actions/channels';
import {getTeams} from 'mattermost-redux/actions/teams';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId, getTeamMemberships} 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 'app/selectors/preferences';
import ChannelDrawer from './channel_drawer.js';
@@ -21,12 +20,13 @@ function mapStateToProps(state, ownProps) {
return {
...ownProps,
currentTeamId: getCurrentTeamId(state),
currentChannelId: getCurrentChannelId(state),
currentTeam: getCurrentTeam(state) || {},
currentChannel: getCurrentChannel(state) || {},
currentDisplayName: state.views.channel.displayName,
currentUserId,
isLandscape: isLandscape(state),
isTablet: isTablet(state),
teamsCount: Object.keys(getTeamMemberships(state)).length,
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

@@ -3,16 +3,14 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {StyleSheet} from 'react-native';
import {Dimensions} from 'react-native';
import Swiper from 'react-native-swiper';
import {changeOpacity} from 'app/utils/theme';
import Swiper from 'app/components/swiper';
export default class DrawerSwiper extends PureComponent {
export default class SwiperIos extends PureComponent {
static propTypes = {
children: PropTypes.node.isRequired,
deviceWidth: PropTypes.number.isRequired,
onPageSelected: PropTypes.func,
openDrawerOffset: PropTypes.number,
showTeams: PropTypes.bool.isRequired,
@@ -24,47 +22,49 @@ export default class DrawerSwiper extends PureComponent {
openDrawerOffset: 0
};
runOnLayout = (shouldRun = true) => {
this.refs.swiper.runOnLayout = shouldRun;
};
resetPage = () => {
this.refs.swiper.scrollToIndex(1, false);
};
scrollToStart = () => {
this.refs.swiper.scrollToStart();
};
swiperPageSelected = (index) => {
this.props.onPageSelected(index);
swiperPageSelected = (e, state, context) => {
this.props.onPageSelected(context.state.index);
};
showTeamsPage = () => {
this.refs.swiper.scrollToIndex(0, true);
this.refs.swiper.scrollBy(-1, true);
};
resetPage = () => {
this.refs.swiper.scrollBy(1, false);
};
render() {
const {
children,
deviceWidth,
openDrawerOffset,
showTeams,
theme
} = this.props;
const initialPage = React.Children.count(children) - 1;
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'
initialPage={initialPage}
onIndexChanged={this.swiperPageSelected}
paginationStyle={style.pagination}
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'}
@@ -74,10 +74,3 @@ export default class DrawerSwiper extends PureComponent {
);
}
}
const style = StyleSheet.create({
pagination: {
bottom: 0,
position: 'absolute'
}
});

View File

@@ -4,22 +4,36 @@
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, getJoinableTeams} from 'mattermost-redux/selectors/entities/teams';
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 {getTheme} from 'app/selectors/preferences';
import {getMySortedTeams} from 'app/selectors/teams';
import {removeProtocol} from 'app/utils/url';
import TeamsList from './teams_list';
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 {
canCreateTeams: false,
joinableTeams: getJoinableTeams(state),
currentChannelId: getCurrentChannelId(state),
currentTeamId: getCurrentTeamId(state),
currentUrl: removeProtocol(getCurrentUrl(state)),
teams: getMySortedTeams(state),
teams: getMyTeams(state).sort(sortTeams.bind(null, (user.locale))),
theme: getTheme(state),
...ownProps
};
@@ -28,7 +42,8 @@ function mapStateToProps(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,24 +12,28 @@ 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,
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,
teams: PropTypes.array.isRequired,
theme: PropTypes.object.isRequired
@@ -43,17 +48,21 @@ class TeamsList extends PureComponent {
}
selectTeam = (team) => {
requestAnimationFrame(() => {
const {actions, closeChannelDrawer, currentTeamId} = this.props;
if (team.id !== currentTeamId) {
actions.handleTeamChange(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,18 +88,83 @@ class TeamsList extends PureComponent {
theme
}
});
});
keyExtractor = (team) => {
return team.id;
}
};
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}
team={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>
);
};
@@ -103,7 +177,7 @@ class TeamsList extends PureComponent {
moreAction = (
<TouchableHighlight
style={styles.moreActionContainer}
onPress={this.goToSelectTeam}
onPress={() => preventDoubleTap(this.goToSelectTeam)}
underlayColor={changeOpacity(theme.sidebarHeaderBg, 0.5)}
>
<Text
@@ -130,7 +204,7 @@ class TeamsList extends PureComponent {
<FlatList
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,24 +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, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams';
import {removeProtocol} from 'app/utils/url';
import TeamsListItem from './teams_list_item.js';
function mapStateToProps(state, ownProps) {
return {
currentTeamId: getCurrentTeamId(state),
currentUrl: removeProtocol(getCurrentUrl(state)),
teamMember: getTeamMemberships(state)[ownProps.team.id],
theme: getTheme(state),
...ownProps
};
}
export default connect(mapStateToProps)(TeamsListItem);

View File

@@ -1,171 +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,
selectTeam: PropTypes.func.isRequired,
team: PropTypes.object.isRequired,
teamMember: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired
};
selectTeam = wrapWithPreventDoubleTap(() => {
this.props.selectTeam(this.props.team);
});
render() {
const {
currentTeamId,
currentUrl,
team,
teamMember,
theme
} = this.props;
const styles = getStyleSheet(theme);
let current;
let badge;
if (team.id === currentTeamId) {
current = (
<View style={styles.checkmarkContainer}>
<IonIcon
name='md-checkmark'
style={styles.checkmark}
/>
</View>
);
}
let badgeCount = 0;
if (teamMember.mention_count) {
badgeCount = teamMember.mention_count;
} else if (teamMember.msg_count) {
badgeCount = -1;
}
if (badgeCount) {
badge = (
<Badge
style={styles.badge}
countStyle={styles.mention}
count={badgeCount}
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}>
{team.display_name.substr(0, 2).toUpperCase()}
</Text>
</View>
<View style={styles.teamNameContainer}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.teamName}
>
{team.display_name}
</Text>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.teamUrl}
>
{`${currentUrl}/${team.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

@@ -71,7 +71,7 @@ class ChannelIntro extends PureComponent {
style={style.profile}
>
<ProfilePicture
userId={member.id}
user={member}
size={64}
statusBorderWidth={2}
statusSize={25}

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

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

@@ -12,7 +12,7 @@ export default class Drawer extends BaseDrawer {
};
// To fix the android onLayout issue give this a value of 100% as it does not need another one
getMainHeight = () => '100%';
getHeight = () => '100%';
processTapGestures = () => {
// Note that we explicitly don't support tap to open or double tap because I didn't copy them over

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,7 +6,6 @@ import {createSelector} from 'reselect';
import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
import {getDimensions} from 'app/selectors/device';
import {getTheme} from 'app/selectors/preferences';
import {CategoryNames, Emojis, EmojiIndicesByCategory} from 'app/utils/emojis';
@@ -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

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

@@ -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,7 +7,6 @@ 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 '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];
}

View File

@@ -1,12 +1,12 @@
// Copyright (c) 2016-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 FormattedTime extends React.PureComponent {
class FormattedTime extends Component {
static propTypes = {
intl: intlShape.isRequired,
value: PropTypes.any.isRequired,

View File

@@ -1,11 +1,11 @@
// 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 {KeyboardAvoidingView, Platform, View} from 'react-native';
export default class KeyboardLayout extends React.PureComponent {
export default class KeyboardLayout extends Component {
static propTypes = {
behaviour: PropTypes.string,
children: PropTypes.node,

View File

@@ -29,7 +29,6 @@ export default class Markdown extends PureComponent {
baseTextStyle: CustomPropTypes.Style,
blockStyles: PropTypes.object,
emojiSizes: PropTypes.object,
fontSizes: PropTypes.object,
isSearchResult: PropTypes.bool,
navigator: PropTypes.object.isRequired,
onLongPress: PropTypes.func,
@@ -53,25 +52,16 @@ export default class Markdown extends PureComponent {
text: 20
},
android: {
heading1: 60,
heading2: 60,
heading3: 60,
heading4: 60,
heading5: 60,
heading6: 60,
text: 45
heading1: 80,
heading2: 80,
heading3: 80,
heading4: 80,
heading5: 80,
heading6: 80,
text: 65
}
})
},
fontSizes: {
heading1: 17,
heading2: 17,
heading3: 17,
heading4: 17,
heading5: 17,
heading6: 17,
text: 15
},
onLongPress: () => true
};
@@ -167,14 +157,11 @@ export default class Markdown extends PureComponent {
renderEmoji = ({context, emojiName, literal}) => {
let size;
let fontSize;
const headingType = context.find((type) => type.startsWith('heading'));
if (headingType) {
size = this.props.emojiSizes[headingType];
fontSize = this.props.fontSizes[headingType];
} else {
size = this.props.emojiSizes.text;
fontSize = this.props.fontSizes.text;
}
return (
@@ -182,7 +169,6 @@ export default class Markdown extends PureComponent {
emojiName={emojiName}
literal={literal}
size={size}
fontSize={fontSize}
textStyle={this.computeTextStyle(this.props.baseTextStyle, context)}
/>
);

View File

@@ -6,21 +6,17 @@ import {connect} from 'react-redux';
import {close as closeWebSocket, init as initWebSocket} from 'mattermost-redux/actions/websocket';
import {getConnection} from 'app/selectors/device';
import OfflineIndicator from './offline_indicator';
function mapStateToProps(state, ownProps) {
const {websocket} = state.requests.general;
const {appState} = state.entities.general;
const webSocketStatus = websocket.status;
const isConnecting = websocket.error >= 2;
const {connection} = state.views;
return {
appState,
isConnecting,
isOnline: getConnection(state),
webSocketStatus,
isOnline: connection,
websocket,
...ownProps
};
}

View File

@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
import {
ActivityIndicator,
Animated,
Dimensions,
Platform,
StyleSheet,
TouchableOpacity,
@@ -31,9 +32,8 @@ export default class OfflineIndicator extends PureComponent {
initWebSocket: PropTypes.func.isRequired
}).isRequired,
appState: PropTypes.bool,
isConnecting: PropTypes.bool,
isOnline: PropTypes.bool,
webSocketStatus: PropTypes.string
websocket: PropTypes.object
};
static defaultProps: {
@@ -45,6 +45,7 @@ export default class OfflineIndicator extends PureComponent {
super(props);
this.state = {
forced: false,
network: null,
top: new Animated.Value(INITIAL_TOP)
};
@@ -53,15 +54,12 @@ export default class OfflineIndicator extends PureComponent {
}
componentWillReceiveProps(nextProps) {
const {appState, isConnecting, isOnline, webSocketStatus} = nextProps;
if (appState) { // The app is in the foreground
if (isOnline) {
if (this.state.network && webSocketStatus === RequestStatus.SUCCESS) {
// Show the connected animation only if we had a previous network status
this.connected();
} else if ((webSocketStatus === RequestStatus.STARTED || webSocketStatus === RequestStatus.FAILURE) && isConnecting) {
// Show the connecting bar if it failed to connect at least twice
if (this.props.appState || nextProps.appState) {
if (this.state.forced || nextProps.isOnline) {
if (nextProps.websocket.status === RequestStatus.STARTED || nextProps.websocket.status === RequestStatus.FAILURE) {
this.connecting();
} else if (nextProps.websocket.status === RequestStatus.SUCCESS) {
this.connected();
}
} else {
this.offline();
@@ -76,17 +74,16 @@ export default class OfflineIndicator extends PureComponent {
};
connect = () => {
const {actions, isOnline, webSocketStatus} = this.props;
const {closeWebSocket, initWebSocket} = actions;
initWebSocket(Platform.OS);
const {closeWebSocket, initWebSocket} = this.props.actions;
this.setState({forced: true}, () => {
initWebSocket(Platform.OS);
// close the WS connection after trying for 5 seconds
setTimeout(() => {
if (webSocketStatus !== RequestStatus.SUCCESS) {
// set forced to be false after trying for 3 seconds
setTimeout(() => {
closeWebSocket(true);
this.setState({network: isOnline ? OFFLINE : CONNECTING});
}
}, 5000);
this.setState({forced: false, network: OFFLINE});
}, 3000);
});
};
connecting = () => {
@@ -116,7 +113,7 @@ export default class OfflineIndicator extends PureComponent {
)
]).start(() => {
this.backgroundColor.setValue(0);
this.setState({network: null});
this.setState({forced: false});
});
};
@@ -187,8 +184,8 @@ export default class OfflineIndicator extends PureComponent {
}
return (
<Animated.View style={[styles.container, {top: this.state.top, backgroundColor: background}]}>
<Animated.View style={styles.wrapper}>
<Animated.View style={[styles.container, {top: this.state.top}]}>
<Animated.View style={[styles.wrapper, {backgroundColor: background}]}>
<FormattedText
defaultMessage={defaultMessage}
id={i18nId}
@@ -204,7 +201,7 @@ export default class OfflineIndicator extends PureComponent {
const styles = StyleSheet.create({
container: {
height: HEIGHT,
width: '100%',
width: Dimensions.get('window').width,
zIndex: 9,
position: 'absolute'
},
@@ -214,7 +211,8 @@ const styles = StyleSheet.create({
height: HEIGHT,
flexDirection: 'row',
paddingLeft: 12,
paddingRight: 5
paddingRight: 5,
backgroundColor: 'red'
},
message: {
color: '#FFFFFF',

View File

@@ -39,11 +39,11 @@ export default class OptionsContext extends PureComponent {
};
handleHideUnderlay = () => {
this.props.toggleSelected(false, this.props.actions.length > 0);
this.props.toggleSelected(false);
};
handleShowUnderlay = () => {
this.props.toggleSelected(true, this.props.actions.length > 0);
this.props.toggleSelected(true);
};
render() {

View File

@@ -17,24 +17,12 @@ export default class OptionsContext extends PureComponent {
actions: []
};
handleHideUnderlay = () => {
if (!this.isShowing) {
this.props.toggleSelected(false, false);
}
};
handleShowUnderlay = () => {
this.props.toggleSelected(true, false);
};
handleHide = () => {
this.isShowing = false;
this.props.toggleSelected(false, this.props.actions.length > 0);
this.props.toggleSelected(false);
};
handleShow = () => {
this.isShowing = this.props.actions.length > 0;
this.props.toggleSelected(true, this.isShowing);
this.props.toggleSelected(true);
};
hide = () => {
@@ -45,21 +33,14 @@ export default class OptionsContext extends PureComponent {
this.refs.toolTip.showMenu();
};
handlePress = () => {
this.props.toggleSelected(false, this.props.actions.length > 0);
this.props.onPress();
};
render() {
return (
<ToolTip
onHideUnderlay={this.handleHideUnderlay}
onShowUnderlay={this.handleShowUnderlay}
ref='toolTip'
actions={this.props.actions}
arrowDirection='down'
longPress={true}
onPress={this.handlePress}
onPress={this.props.onPress}
underlayColor='transparent'
onShow={this.handleShow}
onHide={this.handleHide}

View File

@@ -49,7 +49,6 @@ class Post extends PureComponent {
navigator: PropTypes.object,
roles: PropTypes.string,
shouldRenderReplyButton: PropTypes.bool,
showFullDate: PropTypes.bool,
tooltipVisible: PropTypes.bool,
theme: PropTypes.object.isRequired,
onPress: PropTypes.func,
@@ -157,24 +156,24 @@ class Post extends PureComponent {
const {intl, navigator, post, theme} = this.props;
MaterialIcon.getImageSource('close', 20, theme.sidebarHeaderTextColor).
then((source) => {
navigator.showModal({
screen: 'AddReaction',
title: intl.formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}),
animated: true,
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
navBarBackgroundColor: theme.sidebarHeaderBg,
navBarButtonColor: theme.sidebarHeaderTextColor,
screenBackgroundColor: theme.centerChannelBg
},
passProps: {
post,
closeButton: source,
onEmojiPress: this.handleAddReactionToPost
}
});
then((source) => {
navigator.showModal({
screen: 'AddReaction',
title: intl.formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}),
animated: true,
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
navBarBackgroundColor: theme.sidebarHeaderBg,
navBarButtonColor: theme.sidebarHeaderTextColor,
screenBackgroundColor: theme.centerChannelBg
},
passProps: {
post,
closeButton: source,
onEmojiPress: this.handleAddReactionToPost
}
});
});
}
handleFailedPostPress = () => {
@@ -281,17 +280,15 @@ class Post extends PureComponent {
};
viewUserProfile = () => {
const {isSearchResult, tooltipVisible} = this.props;
const {isSearchResult} = this.props;
if (!isSearchResult && !tooltipVisible) {
if (!isSearchResult) {
preventDoubleTap(this.goToUserProfile, this);
}
};
toggleSelected = (selected, tooltip) => {
if (tooltip) {
this.props.actions.setPostTooltipVisible(selected);
}
toggleSelected = (selected) => {
this.props.actions.setPostTooltipVisible(selected);
this.setState({selected});
};
@@ -304,7 +301,6 @@ class Post extends PureComponent {
post,
renderReplies,
shouldRenderReplyButton,
showFullDate,
theme
} = this.props;
const style = getStyleSheet(theme);
@@ -328,7 +324,6 @@ class Post extends PureComponent {
createAt={post.create_at}
isSearchResult={isSearchResult}
shouldRenderReplyButton={shouldRenderReplyButton}
showFullDate={showFullDate}
onPress={this.handleReply}
onViewUserProfile={this.viewUserProfile}
renderReplies={renderReplies}

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getOpenGraphMetadata} from 'mattermost-redux/actions/posts';
import {getTheme} from 'app/selectors/preferences';
import PostAttachmentOpenGraph from './post_attachment_opengraph';
function mapStateToProps(state, ownProps) {
return {
...ownProps,
theme: getTheme(state)
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
getOpenGraphMetadata
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(PostAttachmentOpenGraph);

View File

@@ -1,252 +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 {
Dimensions,
Image,
Linking,
PixelRatio,
Text,
TouchableOpacity,
View
} from 'react-native';
import {getNearestPoint} from 'app/utils/opengraph';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const LARGE_IMAGE_MIN_WIDTH = 150;
const LARGE_IMAGE_MIN_RATIO = (16 / 9);
const MAX_IMAGE_HEIGHT = 150;
const THUMBNAIL_SIZE = 75;
export default class PostAttachmentOpenGraph extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
getOpenGraphMetadata: PropTypes.func.isRequired
}).isRequired,
link: PropTypes.string.isRequired,
openGraphData: PropTypes.object,
theme: PropTypes.object.isRequired
};
constructor(props) {
super(props);
this.state = {
imageLoaded: false,
hasLargeImage: false
};
}
componentWillMount() {
this.fetchData(this.props.link, this.props.openGraphData);
}
componentWillReceiveProps(nextProps) {
if (nextProps.link !== this.props.link) {
this.setState({imageLoaded: false});
this.fetchData(nextProps.link, nextProps.openGraphData);
}
}
calculateLargeImageDimensions = (width, height) => {
const {width: deviceWidth} = Dimensions.get('window');
let maxHeight = MAX_IMAGE_HEIGHT;
let maxWidth = deviceWidth - 88;
if (height <= MAX_IMAGE_HEIGHT) {
maxHeight = height;
} else {
maxHeight = (height / width) * maxWidth;
if (maxHeight > MAX_IMAGE_HEIGHT) {
maxHeight = MAX_IMAGE_HEIGHT;
}
}
if (height > width) {
maxWidth = (width / height) * maxHeight;
}
return {width: maxWidth, height: maxHeight};
};
calculateSmallImageDimensions = (width, height) => {
const {width: deviceWidth} = Dimensions.get('window');
const offset = deviceWidth - 170;
let ratio;
let maxWidth;
let maxHeight;
if (width >= height) {
ratio = width / height;
maxWidth = THUMBNAIL_SIZE;
maxHeight = PixelRatio.roundToNearestPixel(maxWidth / ratio);
} else {
ratio = height / width;
maxHeight = THUMBNAIL_SIZE;
maxWidth = PixelRatio.roundToNearestPixel(maxHeight / ratio);
}
return {width: maxWidth, height: maxHeight, offset};
};
fetchData(url, openGraphData) {
if (!openGraphData) {
this.props.actions.getOpenGraphMetadata(url);
}
}
getBestImageUrl(data) {
if (!data.images) {
return null;
}
const bestDimensions = {
width: Dimensions.get('window').width - 88,
height: MAX_IMAGE_HEIGHT
};
const bestImage = getNearestPoint(bestDimensions, data.images, 'width', 'height');
return bestImage.secure_url || bestImage.url;
}
getImageSize = (imageUrl) => {
if (!this.state.imageLoaded) {
Image.getSize(imageUrl, (width, height) => {
const {hasLargeImage} = this.state;
const imageRatio = width / height;
let isLarge = false;
let dimensions;
if (width >= LARGE_IMAGE_MIN_WIDTH && imageRatio >= LARGE_IMAGE_MIN_RATIO && !hasLargeImage) {
isLarge = true;
dimensions = this.calculateLargeImageDimensions(width, height);
} else {
dimensions = this.calculateSmallImageDimensions(width, height);
}
this.setState({
...dimensions,
hasLargeImage: isLarge,
imageLoaded: true
});
}, () => null);
}
};
goToLink = () => {
Linking.openURL(this.props.link);
};
render() {
const {openGraphData, theme} = this.props;
const {hasLargeImage, height, imageLoaded, offset, width} = this.state;
if (!openGraphData || !openGraphData.description) {
return null;
}
const style = getStyleSheet(theme);
const imageUrl = this.getBestImageUrl(openGraphData);
const isThumbnail = !hasLargeImage && imageLoaded;
if (imageUrl) {
this.getImageSize(imageUrl);
}
return (
<View style={style.container}>
<View style={style.flex}>
<Text
style={style.siteTitle}
numberOfLines={1}
ellipsizeMode='tail'
>
{openGraphData.site_name}
</Text>
</View>
<View style={style.wrapper}>
<TouchableOpacity
style={isThumbnail ? {width: offset} : style.flex}
onPress={this.goToLink}
>
<Text
style={style.siteSubtitle}
numberOfLines={3}
ellipsizeMode='tail'
>
{openGraphData.title}
</Text>
</TouchableOpacity>
{isThumbnail &&
<View style={style.thumbnail}>
<Image
style={[style.image, {width, height}]}
source={{uri: imageUrl}}
resizeMode='cover'
/>
</View>
}
</View>
<View style={style.flex}>
<Text
style={style.siteDescription}
numberOfLines={5}
ellipsizeMode='tail'
>
{openGraphData.description}
</Text>
</View>
{hasLargeImage && imageLoaded &&
<Image
style={[style.image, {width, height}]}
source={{uri: imageUrl}}
resizeMode='cover'
/>
}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderWidth: 1,
marginTop: 10,
padding: 10
},
flex: {
flex: 1
},
wrapper: {
flex: 1,
flexDirection: 'row'
},
siteTitle: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.5),
marginBottom: 10
},
siteSubtitle: {
fontSize: 14,
color: theme.linkColor,
marginBottom: 10
},
siteDescription: {
fontSize: 13,
color: changeOpacity(theme.centerChannelColor, 0.7),
marginBottom: 10
},
image: {
borderRadius: 3
},
thumbnail: {
flex: 1,
alignItems: 'flex-end',
justifyContent: 'flex-start'
}
};
});

View File

@@ -8,7 +8,7 @@ import {flagPost, unflagPost} from 'mattermost-redux/actions/posts';
import {Posts} from 'mattermost-redux/constants';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences';
import {isPostFlagged, isPostEphemeral, isSystemMessage} from 'mattermost-redux/utils/post_utils';
import {isPostFlagged, isSystemMessage} from 'mattermost-redux/utils/post_utils';
import {getTheme} from 'app/selectors/preferences';
@@ -20,14 +20,13 @@ function mapStateToProps(state, ownProps) {
return {
...ownProps,
postProps: post.props || {},
attachments: post.props && post.props.attachments,
fileIds: post.file_ids,
hasBeenDeleted: post.state === Posts.POST_DELETED,
hasReactions: post.has_reactions,
isFailed: post.failed,
isFlagged: isPostFlagged(post.id, myPreferences),
isPending: post.id === post.pending_post_id,
isPostEphemeral: isPostEphemeral(post),
isSystemMessage: isSystemMessage(post),
message: post.message,
theme: getTheme(state)

View File

@@ -16,8 +16,7 @@ import FileAttachmentList from 'app/components/file_attachment_list';
import FormattedText from 'app/components/formatted_text';
import Markdown from 'app/components/markdown';
import OptionsContext from 'app/components/options_context';
import PostBodyAdditionalContent from 'app/components/post_body_additional_content';
import SlackAttachments from 'app/components/slack_attachments';
import {emptyFunction} from 'app/utils/general';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from 'app/utils/markdown';
@@ -30,6 +29,7 @@ class PostBody extends PureComponent {
flagPost: PropTypes.func.isRequired,
unflagPost: PropTypes.func.isRequired
}).isRequired,
attachments: PropTypes.array,
canDelete: PropTypes.bool,
canEdit: PropTypes.bool,
fileIds: PropTypes.array,
@@ -39,7 +39,6 @@ class PostBody extends PureComponent {
isFailed: PropTypes.bool,
isFlagged: PropTypes.bool,
isPending: PropTypes.bool,
isPostEphemeral: PropTypes.bool,
isSearchResult: PropTypes.bool,
isSystemMessage: PropTypes.bool,
message: PropTypes.string,
@@ -50,7 +49,6 @@ class PostBody extends PureComponent {
onPostEdit: PropTypes.func,
onPress: PropTypes.func,
postId: PropTypes.string.isRequired,
postProps: PropTypes.object,
renderReplyBar: PropTypes.func,
theme: PropTypes.object,
toggleSelected: PropTypes.func
@@ -125,6 +123,25 @@ class PostBody extends PureComponent {
return attachments;
}
renderSlackAttachments = (baseStyle, blockStyles, textStyles) => {
const {attachments, navigator, theme} = this.props;
if (attachments && attachments.length) {
return (
<SlackAttachments
attachments={attachments}
baseTextStyle={baseStyle}
blockStyles={blockStyles}
navigator={navigator}
textStyles={textStyles}
theme={theme}
/>
);
}
return null;
};
render() {
const {
canDelete,
@@ -134,7 +151,6 @@ class PostBody extends PureComponent {
isFailed,
isFlagged,
isPending,
isPostEphemeral,
isSearchResult,
isSystemMessage,
intl,
@@ -145,7 +161,6 @@ class PostBody extends PureComponent {
onPostEdit,
onPress,
postId,
postProps,
renderReplyBar,
theme,
toggleSelected
@@ -159,7 +174,7 @@ class PostBody extends PureComponent {
const isPendingOrFailedPost = isPending || isFailed;
// we should check for the user roles and permissions
if (!isPendingOrFailedPost && !isSearchResult && !isSystemMessage && !isPostEphemeral) {
if (!isPendingOrFailedPost && !isSearchResult) {
if (isFlagged) {
actions.push({
text: formatMessage({id: 'post_info.mobile.unflag', defaultMessage: 'Unflag'}),
@@ -186,7 +201,6 @@ class PostBody extends PureComponent {
});
}
let body;
let messageComponent;
if (hasBeenDeleted) {
messageComponent = (
@@ -196,7 +210,6 @@ class PostBody extends PureComponent {
defaultMessage='(message deleted)'
/>
);
body = (<View>{messageComponent}</View>);
} else if (message.length) {
messageComponent = (
<View style={{flexDirection: 'row'}}>
@@ -216,52 +229,38 @@ class PostBody extends PureComponent {
);
}
if (!hasBeenDeleted) {
if (isSearchResult) {
body = (
<TouchableHighlight
onHideUnderlay={this.handleHideUnderlay}
onPress={onPress}
onShowUnderlay={this.handleShowUnderlay}
underlayColor='transparent'
>
<View>
{messageComponent}
<PostBodyAdditionalContent
baseTextStyle={messageStyle}
blockStyles={blockStyles}
navigator={navigator}
message={message}
postProps={postProps}
textStyles={textStyles}
/>
{this.renderFileAttachments()}
</View>
</TouchableHighlight>
);
} else {
body = (
<OptionsContext
actions={actions}
ref='options'
onPress={onPress}
toggleSelected={toggleSelected}
cancelText={formatMessage({id: 'channel_modal.cancel', defaultMessage: 'Cancel'})}
>
let body;
if (isSearchResult) {
body = (
<TouchableHighlight
onHideUnderlay={this.handleHideUnderlay}
onLongPress={this.show}
onPress={onPress}
onShowUnderlay={this.handleShowUnderlay}
underlayColor='transparent'
>
<View>
{messageComponent}
<PostBodyAdditionalContent
baseTextStyle={messageStyle}
blockStyles={blockStyles}
navigator={navigator}
message={message}
postProps={postProps}
textStyles={textStyles}
/>
{this.renderSlackAttachments(messageStyle, blockStyles, textStyles)}
{this.renderFileAttachments()}
{hasReactions && <Reactions postId={postId}/>}
</OptionsContext>
);
}
</View>
</TouchableHighlight>
);
} else {
body = (
<OptionsContext
actions={actions}
ref='options'
onPress={onPress}
toggleSelected={toggleSelected}
cancelText={formatMessage({id: 'channel_modal.cancel', defaultMessage: 'Cancel'})}
>
{messageComponent}
{this.renderSlackAttachments(messageStyle, blockStyles, textStyles)}
{this.renderFileAttachments()}
{hasReactions && <Reactions postId={postId}/>}
</OptionsContext>
);
}
return (
@@ -293,8 +292,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
message: {
color: theme.centerChannelColor,
fontSize: 15,
lineHeight: 20
fontSize: 15
},
messageContainerWithReplyBar: {
flexDirection: 'row',

View File

@@ -1,55 +0,0 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {Preferences} from 'mattermost-redux/constants';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getOpenGraphMetadataForUrl} from 'mattermost-redux/selectors/entities/posts';
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import {ViewTypes} from 'app/constants';
import {getDimensions} from 'app/selectors/device';
import {getTheme} from 'app/selectors/preferences';
import {extractFirstLink} from 'app/utils/url';
import PostBodyAdditionalContent from './post_body_additional_content';
function makeGetFirstLink() {
let link;
let lastMessage;
return (message) => {
if (message !== lastMessage) {
link = extractFirstLink(message);
lastMessage = message;
}
return link;
};
}
function makeMapStateToProps() {
const getFirstLink = makeGetFirstLink();
return function mapStateToProps(state, ownProps) {
const config = getConfig(state);
const previewsEnabled = getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, `${ViewTypes.FEATURE_TOGGLE_PREFIX}${ViewTypes.EMBED_PREVIEW}`);
const link = getFirstLink(ownProps.message);
return {
...ownProps,
...getDimensions(state),
config,
link,
openGraphData: getOpenGraphMetadataForUrl(state, link),
showLinkPreviews: previewsEnabled && config.EnableLinkPreviews === 'true',
theme: getTheme(state)
};
};
}
export default connect(makeMapStateToProps)(PostBodyAdditionalContent);

View File

@@ -1,284 +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 {
Image,
Linking,
Platform,
StyleSheet,
TouchableWithoutFeedback,
View
} from 'react-native';
import {YouTubeStandaloneAndroid, YouTubeStandaloneIOS} from 'react-native-youtube';
import youTubeVideoId from 'youtube-video-id';
import youtubePlayIcon from 'assets/images/icons/youtube-play-icon.png';
import PostAttachmentOpenGraph from 'app/components/post_attachment_opengraph';
import SlackAttachments from 'app/components/slack_attachments';
import CustomPropTypes from 'app/constants/custom_prop_types';
import {isImageLink, isYoutubeLink} from 'app/utils/url';
const MAX_IMAGE_HEIGHT = 150;
export default class PostBodyAdditionalContent extends PureComponent {
static propTypes = {
baseTextStyle: CustomPropTypes.Style,
blockStyles: PropTypes.object,
config: PropTypes.object,
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
link: PropTypes.string,
message: PropTypes.string.isRequired,
navigator: PropTypes.object.isRequired,
openGraphData: PropTypes.object,
postProps: PropTypes.object.isRequired,
showLinkPreviews: PropTypes.bool.isRequired,
textStyles: PropTypes.object,
theme: PropTypes.object.isRequired
};
constructor(props) {
super(props);
this.state = {
linkLoadError: false,
linkLoaded: false
};
this.mounted = false;
}
componentDidMount() {
this.mounted = true;
this.getImageSize();
}
componentWillUnmount() {
this.mounted = false;
}
componentWillReceiveProps(nextProps) {
if (nextProps.message !== this.props.message) {
this.setState({
linkLoadError: false,
linkLoaded: false
}, () => {
this.getImageSize();
});
}
}
calculateDimensions = (width, height) => {
const {deviceHeight, deviceWidth} = this.props;
let maxHeight = MAX_IMAGE_HEIGHT;
const deviceSize = deviceWidth > deviceHeight ? deviceHeight : deviceWidth;
let maxWidth = deviceSize - 78;
if (height <= MAX_IMAGE_HEIGHT) {
maxHeight = height;
} else {
maxHeight = (height / width) * maxWidth;
if (maxHeight > MAX_IMAGE_HEIGHT) {
maxHeight = MAX_IMAGE_HEIGHT;
}
}
if (height > width) {
maxWidth = (width / height) * maxHeight;
}
return {width: maxWidth, height: maxHeight};
};
generateStaticEmbed = (isYouTube, isImage) => {
if (isYouTube || isImage) {
return null;
}
const {link, openGraphData, showLinkPreviews} = this.props;
const attachments = this.getSlackAttachment();
if (attachments) {
return attachments;
}
if (link && showLinkPreviews) {
return (
<PostAttachmentOpenGraph
link={link}
openGraphData={openGraphData}
/>
);
}
return null;
};
getImageSize = () => {
const {link} = this.props;
const {linkLoaded} = this.state;
if (link) {
let imageUrl;
if (isImageLink(link)) {
imageUrl = link;
} else if (isYoutubeLink(link)) {
const videoId = youTubeVideoId(link);
imageUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
}
if (imageUrl && !linkLoaded) {
Image.getSize(imageUrl, (width, height) => {
if (!this.mounted) {
return;
}
const dimensions = this.calculateDimensions(width, height);
this.setState({...dimensions, linkLoaded: true});
}, () => null);
}
}
};
getSlackAttachment = () => {
const {
postProps,
baseTextStyle,
blockStyles,
navigator,
textStyles,
theme
} = this.props;
const {attachments} = postProps;
if (attachments && attachments.length) {
return (
<SlackAttachments
attachments={attachments}
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
navigator={navigator}
textStyles={textStyles}
theme={theme}
/>
);
}
return null;
};
generateToggleableEmbed = (isImage, isYouTube) => {
const {link} = this.props;
const {width, height} = this.state;
if (link) {
if (isYouTube) {
const videoId = youTubeVideoId(link);
const imgUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
return (
<TouchableWithoutFeedback
style={styles.imageContainer}
{...this.responder}
onPress={this.playYouTubeVideo}
>
<Image
style={[styles.image, {width, height}]}
source={{uri: imgUrl}}
resizeMode={'cover'}
onError={this.handleLinkLoadError}
>
<TouchableWithoutFeedback onPress={this.playYouTubeVideo}>
<Image
source={youtubePlayIcon}
onPress={this.playYouTubeVideo}
/>
</TouchableWithoutFeedback>
</Image>
</TouchableWithoutFeedback>
);
}
if (isImage) {
return (
<View style={styles.imageContainer}>
<Image
style={[styles.image, {width, height}]}
source={{uri: link}}
resizeMode={'cover'}
onError={this.handleLinkLoadError}
/>
</View>
);
}
}
return null;
};
playYouTubeVideo = () => {
const {link} = this.props;
const videoId = youTubeVideoId(link);
if (Platform.OS === 'ios') {
YouTubeStandaloneIOS.playVideo(videoId);
} else {
const {config} = this.props;
if (config.GoogleDeveloperKey) {
YouTubeStandaloneAndroid.playVideo({
apiKey: config.GoogleDeveloperKey,
videoId,
autoplay: true
});
} else {
Linking.openURL(link);
}
}
};
handleLinkLoadError = () => {
this.setState({linkLoadError: true});
};
render() {
const {link, openGraphData} = this.props;
const {linkLoaded, linkLoadError} = this.state;
let isYouTube = false;
let isImage = false;
let isOpenGraph = false;
if (link) {
isYouTube = isYoutubeLink(link);
isImage = isImageLink(link);
isOpenGraph = Boolean(openGraphData && openGraphData.description);
if (((isImage && !isOpenGraph) || isYouTube) && !linkLoadError) {
const embed = this.generateToggleableEmbed(isImage, isYouTube);
if (embed && (linkLoaded || isYouTube)) {
return embed;
}
}
}
return this.generateStaticEmbed(isYouTube, isImage);
}
}
const styles = StyleSheet.create({
imageContainer: {
alignItems: 'flex-start',
flex: 1,
justifyContent: 'flex-start',
marginBottom: 6,
marginTop: 10
},
image: {
alignItems: 'center',
borderRadius: 3,
justifyContent: 'center',
marginVertical: 1
}
});

View File

@@ -3,9 +3,8 @@
import {connect} from 'react-redux';
import {Preferences} from 'mattermost-redux/constants';
import {getPost, makeGetCommentCountForPost} from 'mattermost-redux/selectors/entities/posts';
import {getBool, getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {isPostPendingOrFailed, isSystemMessage} from 'mattermost-redux/utils/post_utils';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
@@ -22,7 +21,6 @@ function makeMapStateToProps() {
const commentedOnUser = getUser(state, ownProps.commentedOnUserId);
const user = getUser(state, post.user_id);
const teammateNameDisplay = getTeammateNameDisplaySetting(state);
const militaryTime = getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time');
return {
...ownProps,
@@ -32,7 +30,6 @@ function makeMapStateToProps() {
displayName: displayUsername(user, teammateNameDisplay),
enablePostUsernameOverride: config.EnablePostUsernameOverride === 'true',
fromWebHook: post.props && post.props.from_webhook === 'true',
militaryTime,
isPendingOrFailedPost: isPostPendingOrFailed(post),
isSystemMessage: isSystemMessage(post),
overrideUsername: post.props && post.props.override_username,

View File

@@ -11,7 +11,6 @@ import {
import FormattedText from 'app/components/formatted_text';
import FormattedTime from 'app/components/formatted_time';
import FormattedDate from 'app/components/formatted_date';
import ReplyIcon from 'app/components/reply_icon';
import {emptyFunction} from 'app/utils/general';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
@@ -28,14 +27,12 @@ export default class PostHeader extends PureComponent {
fromWebHook: PropTypes.bool,
isPendingOrFailedPost: PropTypes.bool,
isSearchResult: PropTypes.bool,
shouldRenderReplyButton: PropTypes.bool,
isSystemMessage: PropTypes.bool,
militaryTime: PropTypes.bool,
onPress: PropTypes.func,
onViewUserProfile: PropTypes.func,
overrideUsername: PropTypes.string,
renderReplies: PropTypes.bool,
shouldRenderReplyButton: PropTypes.bool,
showFullDate: PropTypes.bool,
theme: PropTypes.object.isRequired
};
@@ -143,49 +140,23 @@ export default class PostHeader extends PureComponent {
createAt,
isPendingOrFailedPost,
isSearchResult,
militaryTime,
onPress,
renderReplies,
shouldRenderReplyButton,
showFullDate,
theme
} = this.props;
const style = getStyleSheet(theme);
const showReply = shouldRenderReplyButton || (!commentedOnDisplayName && commentCount > 0 && renderReplies);
let dateComponent;
if (showFullDate) {
dateComponent = (
<View style={style.datetime}>
<Text style={style.time}>
<FormattedDate value={createAt}/>
</Text>
<Text style={style.time}>
<FormattedTime
hour12={!militaryTime}
value={createAt}
/>
</Text>
</View>
);
} else {
dateComponent = (
<Text style={style.time}>
<FormattedTime
hour12={!militaryTime}
value={createAt}
/>
</Text>
);
}
return (
<View>
<View style={[style.postInfoContainer, (isPendingOrFailedPost && style.pendingPost)]}>
<View style={{flexDirection: 'row', flex: 1}}>
{this.getDisplayName(style)}
<View style={style.timeContainer}>
{dateComponent}
<Text style={style.time}>
<FormattedTime value={createAt}/>
</Text>
</View>
</View>
{showReply &&
@@ -232,10 +203,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
timeContainer: {
justifyContent: 'center'
},
datetime: {
flex: 1,
flexDirection: 'row'
},
time: {
color: theme.centerChannelColor,
fontSize: 13,

View File

@@ -4,7 +4,6 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
View
} from 'react-native';
import FlatList from 'app/components/inverted_flat_list';
@@ -25,7 +24,8 @@ export default class PostList extends PureComponent {
actions: PropTypes.shape({
refreshChannelWithRetry: PropTypes.func.isRequired
}).isRequired,
channelId: PropTypes.string,
channel: PropTypes.object,
channelIsLoading: PropTypes.bool.isRequired,
currentUserId: PropTypes.string,
indicateNewMessages: PropTypes.bool,
isLoadingMore: PropTypes.bool,
@@ -34,7 +34,6 @@ export default class PostList extends PureComponent {
loadMore: PropTypes.func,
navigator: PropTypes.object,
onPostPress: PropTypes.func,
onRefresh: PropTypes.func,
posts: PropTypes.array.isRequired,
refreshing: PropTypes.bool,
renderReplies: PropTypes.bool,
@@ -43,6 +42,11 @@ export default class PostList extends PureComponent {
theme: PropTypes.object.isRequired
};
static defaultProps = {
channel: {},
channelIsLoading: false
};
getPostsWithDates = () => {
const {posts, indicateNewMessages, currentUserId, lastViewedAt, showLoadMore} = this.props;
const list = addDatesToPostList(posts, {indicateNewMessages, currentUserId, lastViewedAt});
@@ -73,25 +77,17 @@ export default class PostList extends PureComponent {
};
onRefresh = () => {
const {
actions,
channelId,
onRefresh
} = this.props;
const {actions, channel} = this.props;
if (channelId) {
actions.refreshChannelWithRetry(channelId);
}
if (onRefresh) {
onRefresh();
if (Object.keys(channel).length) {
actions.refreshChannelWithRetry(channel.id);
}
};
renderChannelIntro = () => {
const {channelId, navigator, refreshing, showLoadMore} = this.props;
const {channel, channelIsLoading, navigator, refreshing, showLoadMore} = this.props;
if (channelId && !showLoadMore && !refreshing) {
if (channel.hasOwnProperty('id') && !showLoadMore && !refreshing && !channelIsLoading) {
return (
<View>
<ChannelIntro navigator={navigator}/>
@@ -134,10 +130,6 @@ export default class PostList extends PureComponent {
return this.renderPost(item);
};
getItem = (data, index) => data[index];
getItemCount = (data) => data.length;
renderPost = (post) => {
const {
isSearchResult,
@@ -163,13 +155,13 @@ export default class PostList extends PureComponent {
};
render() {
const {channelId, refreshing, theme} = this.props;
const {channel, refreshing, theme} = this.props;
const refreshControl = {
refreshing
};
if (channelId) {
if (Object.keys(channel).length) {
refreshControl.onRefresh = this.onRefresh;
}
@@ -181,20 +173,11 @@ export default class PostList extends PureComponent {
keyExtractor={this.keyExtractor}
ListFooterComponent={this.renderChannelIntro}
onEndReached={this.loadMorePosts}
onEndReachedThreshold={0}
onEndReachedThreshold={700}
{...refreshControl}
renderItem={this.renderItem}
theme={theme}
getItem={this.getItem}
getItemCount={this.getItemCount}
contentContainerStyle={styles.postListContent}
/>
);
}
}
const styles = StyleSheet.create({
postListContent: {
paddingTop: 5
}
});

View File

@@ -4,6 +4,7 @@
import {connect} from 'react-redux';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {isSystemMessage} from 'mattermost-redux/utils/post_utils';
import {getTheme} from 'app/selectors/preferences';
@@ -13,6 +14,7 @@ import PostProfilePicture from './post_profile_picture';
function mapStateToProps(state, ownProps) {
const {config} = state.entities.general;
const post = getPost(state, ownProps.postId);
const user = getUser(state, post.user_id);
return {
...ownProps,
@@ -20,7 +22,7 @@ function mapStateToProps(state, ownProps) {
fromWebHook: post.props && post.props.from_webhook === 'true',
isSystemMessage: isSystemMessage(post),
overrideIconUrl: post.props && post.props.override_icon_url,
userId: post.user_id,
user,
theme: getTheme(state)
};
}

View File

@@ -20,7 +20,7 @@ function PostProfilePicture(props) {
onViewUserProfile,
overrideIconUrl,
theme,
userId
user
} = props;
if (isSystemMessage) {
return (
@@ -51,7 +51,7 @@ function PostProfilePicture(props) {
return (
<ProfilePicture
userId={userId}
user={user}
size={PROFILE_PICTURE_SIZE}
/>
);
@@ -60,7 +60,7 @@ function PostProfilePicture(props) {
return (
<TouchableOpacity onPress={onViewUserProfile}>
<ProfilePicture
userId={userId}
user={user}
size={PROFILE_PICTURE_SIZE}
/>
</TouchableOpacity>
@@ -74,7 +74,7 @@ PostProfilePicture.propTypes = {
overrideIconUrl: PropTypes.string,
onViewUserProfile: PropTypes.func,
theme: PropTypes.object,
userId: PropTypes.string
user: PropTypes.object
};
PostProfilePicture.defaultProps = {

View File

@@ -1,177 +0,0 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Platform,
StyleSheet,
TouchableOpacity
} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import ImagePicker from 'react-native-image-picker';
import {changeOpacity} from 'app/utils/theme';
export default class AttachmentButton extends PureComponent {
static propTypes = {
blurTextBox: PropTypes.func.isRequired,
navigator: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
uploadFiles: PropTypes.func.isRequired
};
attachFileFromCamera = () => {
const options = {
quality: 0.7,
noData: true,
storageOptions: {
cameraRoll: true,
waitUntilSaved: true
}
};
ImagePicker.launchCamera(options, (response) => {
if (response.error || response.didCancel) {
return;
}
this.uploadFiles([response]);
});
};
attachFileFromLibrary = () => {
const options = {
quality: 0.7,
noData: true
};
if (Platform.OS === 'ios') {
options.mediaType = 'mixed';
}
ImagePicker.launchImageLibrary(options, (response) => {
if (response.error || response.didCancel) {
return;
}
this.uploadFiles([response]);
});
};
attachVideoFromLibraryAndroid = () => {
const options = {
quality: 0.7,
mediaType: 'video',
noData: true
};
ImagePicker.launchImageLibrary(options, (response) => {
if (response.error || response.didCancel) {
return;
}
this.uploadFiles([response]);
});
}
uploadFiles = (images) => {
this.props.uploadFiles(images);
};
handleFileAttachmentOption = (action) => {
this.props.navigator.dismissModal({
animationType: 'none'
});
// Have to wait to launch the library attachment action.
// If we call the action after dismissModal with no delay then the
// Wix navigator will dismiss the library attachment modal as well.
setTimeout(() => {
if (typeof action === 'function') {
action();
}
}, 100);
}
showFileAttachmentOptions = () => {
this.props.blurTextBox();
const options = {
items: [{
action: () => this.handleFileAttachmentOption(this.attachFileFromCamera),
text: {
id: 'mobile.file_upload.camera',
defaultMessage: 'Take Photo or Video'
},
icon: 'camera'
}, {
action: () => this.handleFileAttachmentOption(this.attachFileFromLibrary),
text: {
id: 'mobile.file_upload.library',
defaultMessage: 'Photo Library'
},
icon: 'photo'
}]
};
if (Platform.OS === 'android') {
options.items.push({
action: () => this.handleFileAttachmentOption(this.attachVideoFromLibraryAndroid),
text: {
id: 'mobile.file_upload.video',
defaultMessage: 'Video Library'
},
icon: 'file-video-o'
});
}
this.props.navigator.showModal({
screen: 'OptionsModal',
title: '',
animationType: 'none',
passProps: {
items: options.items
},
navigatorStyle: {
navBarHidden: true,
statusBarHidden: false,
statusBarHideWithNavBar: false,
screenBackgroundColor: 'transparent',
modalPresentationStyle: 'overCurrentContext'
}
});
};
render() {
const {theme} = this.props;
return (
<TouchableOpacity
onPress={this.showFileAttachmentOptions}
style={style.buttonContainer}
>
<Icon
size={30}
style={style.attachIcon}
color={changeOpacity(theme.centerChannelColor, 0.9)}
name='md-add'
/>
</TouchableOpacity>
);
}
}
const style = StyleSheet.create({
attachIcon: {
marginTop: Platform.select({
ios: 2,
android: 0
})
},
buttonContainer: {
height: Platform.select({
ios: 34,
android: 36
}),
width: 45,
alignItems: 'center',
justifyContent: 'center'
}
});

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 {getUsersTyping} from 'mattermost-redux/selectors/entities/typing';
import {getTheme} from 'app/selectors/preferences';
import Typing from './typing';
function mapStateToProps(state) {
return {
theme: getTheme(state),
typing: getUsersTyping(state)
};
}
export default connect(mapStateToProps)(Typing);

View File

@@ -1,102 +0,0 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Animated,
Text
} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
const {View: AnimatedView} = Animated;
export default class Typing extends PureComponent {
static propTypes = {
theme: PropTypes.object.isRequired,
typing: PropTypes.array.isRequired
};
state = {
typingHeight: new Animated.Value(0)
}
componentWillReceiveProps(nextProps) {
if (nextProps.typing.length && !this.props.typing.length) {
this.animateTyping(true);
} else if (!nextProps.typing.length) {
this.animateTyping();
}
}
animateTyping = (show = false) => {
const height = show ? 20 : 0;
Animated.timing(this.state.typingHeight, {
toValue: height,
duration: 200
}).start();
}
renderTyping = () => {
const {typing} = this.props;
const nextTyping = [...typing];
const numUsers = nextTyping.length;
switch (numUsers) {
case 0:
return null;
case 1:
return (
<FormattedText
id='msg_typing.isTyping'
defaultMessage='{user} is typing...'
values={{
user: nextTyping[0]
}}
/>
);
default: {
const last = nextTyping.pop();
return (
<FormattedText
id='msg_typing.areTyping'
defaultMessage='{users} and {last} are typing...'
values={{
users: (nextTyping.join(', ')),
last
}}
/>
);
}
}
};
render() {
const style = getStyleSheet(this.props.theme);
return (
<AnimatedView style={{height: this.state.typingHeight}}>
<Text
style={style.typing}
ellipsizeMode='tail'
numberOfLines={1}
>
{this.renderTyping()}
</Text>
</AnimatedView>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
typing: {
paddingLeft: 10,
paddingTop: 3,
fontSize: 11,
marginBottom: 5,
color: theme.centerChannelColor,
backgroundColor: 'transparent'
}
};
});

View File

@@ -3,43 +3,34 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {createPost} from 'mattermost-redux/actions/posts';
import {userTyping} from 'mattermost-redux/actions/websocket';
import {handleClearFiles, handleRemoveLastFile, handleUploadFiles} from 'app/actions/views/file_upload';
import {getTheme} from 'app/selectors/preferences';
import {canUploadFilesOnMobile} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {addReactionToLatestPost} from 'app/actions/views/emoji';
import {handlePostDraftChanged} from 'app/actions/views/channel';
import {handleClearFiles, handleRemoveLastFile, handleUploadFiles} from 'app/actions/views/file_upload';
import {handleCommentDraftChanged} from 'app/actions/views/thread';
import {getTheme} from 'app/selectors/preferences';
import {getCurrentChannelDraft, getThreadDraft} from 'app/selectors/views';
import {getUsersTyping} from 'mattermost-redux/selectors/entities/typing';
import PostTextbox from './post_textbox';
function mapStateToProps(state, ownProps) {
const currentDraft = ownProps.rootId ? getThreadDraft(state, ownProps.rootId) : getCurrentChannelDraft(state);
return {
...ownProps,
canUploadFiles: canUploadFilesOnMobile(state),
channelIsLoading: state.views.channel.loading,
currentUserId: getCurrentUserId(state),
files: currentDraft.files,
typing: getUsersTyping(state),
theme: getTheme(state),
uploadFileRequestStatus: state.requests.files.uploadFiles.status,
value: currentDraft.draft
uploadFileRequestStatus: state.requests.files.uploadFiles.status
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
addReactionToLatestPost,
createPost,
handleClearFiles,
handleCommentDraftChanged,
handlePostDraftChanged,
handleRemoveLastFile,
handleUploadFiles,
userTyping

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