diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java index 6d16fb9dc..904387cae 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java @@ -199,8 +199,8 @@ public class MainApplication extends NavigationApplication implements INotificat new SvgPackage(), new LinearGradientPackage(), new ReactVideoPackage(), - new RNGestureHandlerPackage() -// new RNReactNativeHapticFeedbackPackage() + new RNGestureHandlerPackage(), + new RNPasteableTextInputPackage() ); } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/RNEditTextOnPasteListener.java b/android/app/src/main/java/com/mattermost/rnbeta/RNEditTextOnPasteListener.java new file mode 100644 index 000000000..cd74aa131 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/rnbeta/RNEditTextOnPasteListener.java @@ -0,0 +1,7 @@ +package com.mattermost.rnbeta; + +import android.net.Uri; + +public interface RNEditTextOnPasteListener { + void onPaste(Uri itemUri); +} diff --git a/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableActionCallback.java b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableActionCallback.java new file mode 100644 index 000000000..44ba12c3d --- /dev/null +++ b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableActionCallback.java @@ -0,0 +1,92 @@ +package com.mattermost.rnbeta; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuItem; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; + +public class RNPasteableActionCallback implements ActionMode.Callback { + + private RNPasteableEditText mEditText; + + RNPasteableActionCallback(RNPasteableEditText editText) { + mEditText = editText; + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + Bundle config = MainApplication.instance.getManagedConfig(); + if (config != null) { + WritableMap result = Arguments.fromBundle(config); + String copyPasteProtection = result.getString("copyAndPasteProtection"); + if (copyPasteProtection.equals("true")) { + disableMenus(menu); + } + } + + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + Uri uri = this.getUriInClipboard(); + if (item.getItemId() == android.R.id.paste && uri != null) { + mEditText.getOnPasteListener().onPaste(uri); + mode.finish(); + } else { + mEditText.onTextContextMenuItem(item.getItemId()); + } + + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + + } + + private void disableMenus(Menu menu) { + for (int i = 0; i < menu.size(); i++) { + MenuItem item = menu.getItem(i); + int id = item.getItemId(); + boolean shouldDisableMenu = ( + id == android.R.id.paste + || id == android.R.id.copy + || id == android.R.id.cut + ); + item.setEnabled(!shouldDisableMenu); + } + } + + private Uri getUriInClipboard() { + ClipboardManager clipboardManager = (ClipboardManager) mEditText.getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboardManager.getPrimaryClip(); + if (clipData == null) { + return null; + } + + ClipData.Item item = clipData.getItemAt(0); + if (item == null) { + return null; + } + + String text = item.getText().toString(); + if (text.length() > 0) { + return null; + } + + return item.getUri(); + } +} diff --git a/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableEditText.java b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableEditText.java new file mode 100644 index 000000000..86d198f1c --- /dev/null +++ b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableEditText.java @@ -0,0 +1,22 @@ +package com.mattermost.rnbeta; + +import android.content.Context; + +import com.facebook.react.views.textinput.ReactEditText; + +public class RNPasteableEditText extends ReactEditText { + + private RNEditTextOnPasteListener mOnPasteListener; + + public RNPasteableEditText(Context context) { + super(context); + } + + public void setOnPasteListener(RNEditTextOnPasteListener listener) { + mOnPasteListener = listener; + } + + public RNEditTextOnPasteListener getOnPasteListener() { + return mOnPasteListener; + } +} diff --git a/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableEditTextOnPasteListener.java b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableEditTextOnPasteListener.java new file mode 100644 index 000000000..909294b58 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableEditTextOnPasteListener.java @@ -0,0 +1,122 @@ +package com.mattermost.rnbeta; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.net.Uri; +import android.util.Patterns; +import android.webkit.MimeTypeMap; +import android.webkit.URLUtil; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.RCTEventEmitter; +import com.mattermost.share.RealPathUtil; + +import java.io.FileNotFoundException; +import java.util.regex.Matcher; + +public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteListener { + + private RNPasteableEditText mEditText; + + RNPasteableEditTextOnPasteListener(RNPasteableEditText editText) { + mEditText = editText; + } + + @Override + public void onPaste(Uri itemUri) { + ReactContext reactContext = (ReactContext)mEditText.getContext(); + String uri = itemUri.toString(); + + WritableArray images = null; + WritableMap error = null; + + String uriMimeType = reactContext.getContentResolver().getType(itemUri); + if (uriMimeType == null) { + return; + } + + // Special handle for Google docs + if (uri.equals("content://com.google.android.apps.docs.editors.kix.editors.clipboard")) { + ClipboardManager clipboardManager = (ClipboardManager) reactContext.getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboardManager.getPrimaryClip(); + if (clipData == null) { + return; + } + + ClipData.Item item = clipData.getItemAt(0); + String htmlText = item.getHtmlText(); + // Find uri from html + Matcher matcher = Patterns.WEB_URL.matcher(htmlText); + if (matcher.find()) { + uri = htmlText.substring(matcher.start(1), matcher.end()); + } + } + + if (uri.startsWith("http")) { + Thread pastImageFromUrlThread = new Thread(new RNPasteableImageFromUrl(reactContext, mEditText, uri)); + pastImageFromUrlThread.start(); + return; + } + + uri = RealPathUtil.getRealPathFromURI(reactContext, itemUri); + if (uri == null) { + return; + } + + // Get type + String extension = MimeTypeMap.getFileExtensionFromUrl(uri); + if (extension == null) { + return; + } + + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (mimeType == null) { + return; + } + + // Get fileName + String fileName = URLUtil.guessFileName(uri, null, mimeType); + + // Get fileSize + long fileSize; + try { + ContentResolver contentResolver = reactContext.getContentResolver(); + AssetFileDescriptor assetFileDescriptor = contentResolver.openAssetFileDescriptor(itemUri, "r"); + if (assetFileDescriptor == null) { + return; + } + + fileSize = assetFileDescriptor.getLength(); + + WritableMap image = Arguments.createMap(); + image.putString("type", mimeType); + image.putDouble("fileSize", fileSize); + image.putString("fileName", fileName); + image.putString("uri", "file://" + uri); + + images = Arguments.createArray(); + images.pushMap(image); + } catch (FileNotFoundException e) { + error = Arguments.createMap(); + error.putString("message", e.getMessage()); + } + + WritableMap event = Arguments.createMap(); + event.putArray("data", images); + event.putMap("error", error); + + reactContext + .getJSModule(RCTEventEmitter.class) + .receiveEvent( + mEditText.getId(), + "onPaste", + event + ); + } +} diff --git a/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableImageFromUrl.java b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableImageFromUrl.java new file mode 100644 index 000000000..546c3f07a --- /dev/null +++ b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableImageFromUrl.java @@ -0,0 +1,76 @@ +package com.mattermost.rnbeta; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.RCTEventEmitter; +import com.facebook.react.views.textinput.ReactEditText; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; + +public class RNPasteableImageFromUrl implements Runnable { + + private ReactContext mContext; + private String mUri; + private ReactEditText mTarget; + + RNPasteableImageFromUrl(ReactContext context, ReactEditText target, String uri) { + mContext = context; + mUri = uri; + mTarget = target; + } + + @Override + public void run() { + WritableArray images = null; + WritableMap error = null; + + try { + URL url = new URL(mUri); + URLConnection u = url.openConnection(); + + // Get type + String mimeType = u.getHeaderField("Content-Type"); + if (!mimeType.startsWith("image")) { + return; + } + + // Get fileSize + long fileSize = Long.parseLong(u.getHeaderField("Content-Length")); + + // Get fileName + String contentDisposition = u.getHeaderField("Content-Disposition"); + int startIndex = contentDisposition.indexOf("filename=\"") + 10; + int endIndex = contentDisposition.length() - 1; + String fileName = contentDisposition.substring(startIndex, endIndex); + + WritableMap image = Arguments.createMap(); + image.putString("type", mimeType); + image.putDouble("fileSize", fileSize); + image.putString("fileName", fileName); + image.putString("uri", mUri); + + images = Arguments.createArray(); + images.pushMap(image); + + } catch (IOException e) { + error = Arguments.createMap(); + error.putString("message", e.getMessage()); + } + + WritableMap event = Arguments.createMap(); + event.putArray("data", images); + event.putMap("error", error); + + mContext + .getJSModule(RCTEventEmitter.class) + .receiveEvent( + mTarget.getId(), + "onPaste", + event + ); + } +} diff --git a/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableTextInputManager.java b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableTextInputManager.java new file mode 100644 index 000000000..e77d2194f --- /dev/null +++ b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableTextInputManager.java @@ -0,0 +1,78 @@ +package com.mattermost.rnbeta; + +import android.support.v13.view.inputmethod.EditorInfoCompat; +import android.support.v13.view.inputmethod.InputConnectionCompat; +import android.support.v4.os.BuildCompat; +import android.text.InputType; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import com.facebook.react.common.MapBuilder; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.views.textinput.ReactEditText; +import com.facebook.react.views.textinput.ReactTextInputManager; + +import java.util.Map; + +import javax.annotation.Nullable; + +public class RNPasteableTextInputManager extends ReactTextInputManager { + + @Override + public String getName() { + return "PasteableTextInputAndroid"; + } + + @Override + public ReactEditText createViewInstance(ThemedReactContext context) { + RNPasteableEditText editText = new RNPasteableEditText(context) { + @Override + public InputConnection onCreateInputConnection(EditorInfo editorInfo) { + final InputConnection ic = super.onCreateInputConnection(editorInfo); + EditorInfoCompat.setContentMimeTypes(editorInfo, + new String [] {"image/*"}); + + + final InputConnectionCompat.OnCommitContentListener callback = + (inputContentInfo, flags, opts) -> { + // read and display inputContentInfo asynchronously + if (BuildCompat.isAtLeastNMR1() && (flags & + InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { + try { + inputContentInfo.requestPermission(); + } + catch (Exception e) { + return false; + } + } + + this.getOnPasteListener().onPaste(inputContentInfo.getContentUri()); + return true; + }; + return InputConnectionCompat.createWrapper(ic, editorInfo, callback); + } + }; + int inputType = editText.getInputType(); + editText.setInputType(inputType & (~InputType.TYPE_TEXT_FLAG_MULTI_LINE)); + editText.setReturnKeyType("done"); + editText.setCustomInsertionActionModeCallback(new RNPasteableActionCallback(editText)); + editText.setCustomSelectionActionModeCallback(new RNPasteableActionCallback(editText)); + return editText; + } + + @Override + protected void addEventEmitters(ThemedReactContext reactContext, ReactEditText editText) { + super.addEventEmitters(reactContext, editText); + + RNPasteableEditText pasteableEditText = (RNPasteableEditText)editText; + pasteableEditText.setOnPasteListener(new RNPasteableEditTextOnPasteListener(pasteableEditText)); + } + + @Nullable + @Override + public Map getExportedCustomBubblingEventTypeConstants() { + Map map = super.getExportedViewConstants(); + map.put("onPaste", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onPaste"))); + return map; + } +} diff --git a/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableTextInputPackage.java b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableTextInputPackage.java new file mode 100644 index 000000000..5716c43b5 --- /dev/null +++ b/android/app/src/main/java/com/mattermost/rnbeta/RNPasteableTextInputPackage.java @@ -0,0 +1,28 @@ +package com.mattermost.rnbeta; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; + +public class RNPasteableTextInputPackage implements ReactPackage { + @Nonnull + @Override + public List createNativeModules(@Nonnull ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Nonnull + @Override + public List createViewManagers(@Nonnull ReactApplicationContext reactContext) { + return Arrays.asList( + new RNPasteableTextInputManager() + ); + } +} diff --git a/app/components/file_upload_preview/file_upload_item/file_upload_item.js b/app/components/file_upload_preview/file_upload_item/file_upload_item.js index c9b2ce71e..ff33c5243 100644 --- a/app/components/file_upload_preview/file_upload_item/file_upload_item.js +++ b/app/components/file_upload_preview/file_upload_item/file_upload_item.js @@ -15,6 +15,8 @@ import FileUploadRetry from 'app/components/file_upload_preview/file_upload_retr import FileUploadRemove from 'app/components/file_upload_preview/file_upload_remove'; import mattermostBucket from 'app/mattermost_bucket'; import {buildFileUploadData, encodeHeaderURIStringToUTF8} from 'app/utils/file'; +import {emptyFunction} from 'app/utils/general'; +import ImageCacheManager from 'app/utils/image_cache_manager'; export default class FileUploadItem extends PureComponent { static propTypes = { @@ -35,8 +37,9 @@ export default class FileUploadItem extends PureComponent { }; componentDidMount() { - if (this.props.file.loading) { - this.uploadFile(); + const {file} = this.props; + if (file.loading) { + this.downloadAndUploadFile(file); } } @@ -45,7 +48,7 @@ export default class FileUploadItem extends PureComponent { const {file: nextFile} = nextProps; if (file.failed !== nextFile.failed && nextFile.loading) { - this.uploadFile(); + this.downloadAndUploadFile(file); } } @@ -109,8 +112,22 @@ export default class FileUploadItem extends PureComponent { return false; }; - uploadFile = async () => { - const {channelId, file} = this.props; + downloadAndUploadFile = async (file) => { + const newFile = {...file}; + if (newFile.localPath.startsWith('http')) { + try { + newFile.localPath = await ImageCacheManager.cache(newFile.name, newFile.localPath, emptyFunction); + } catch (e) { + this.handleUploadError(e); + return; + } + } + + this.uploadFile(newFile); + } + + uploadFile = async (file) => { + const {channelId} = this.props; const fileData = buildFileUploadData(file); const headers = { diff --git a/app/components/file_upload_preview/file_upload_item/file_upload_item.test.js b/app/components/file_upload_preview/file_upload_item/file_upload_item.test.js new file mode 100644 index 000000000..de1fc888f --- /dev/null +++ b/app/components/file_upload_preview/file_upload_item/file_upload_item.test.js @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {shallow} from 'enzyme'; + +import ImageCacheManager from 'app/utils/image_cache_manager'; +import FileUploadItem from './file_upload_item'; + +describe('FileUploadItem', () => { + const props = { + actions: { + handleRemoveFile: jest.fn(), + retryFileUpload: jest.fn(), + uploadComplete: jest.fn(), + uploadFailed: jest.fn(), + }, + channelId: 'channel-id', + file: { + loading: false, + }, + theme: {}, + }; + + describe('downloadAndUploadFile', () => { + test('should upload file', async () => { + const component = shallow(); + component.instance().uploadFile = jest.fn(); + await component.instance().downloadAndUploadFile({ + localPath: 'path/to/file', + }); + expect(component.instance().uploadFile).toHaveBeenCalledWith({ + localPath: 'path/to/file', + }); + }); + + test('should download file if file path is http', async () => { + jest.spyOn(ImageCacheManager, 'cache').mockReturnValue('path/to/downloaded/image'); + const component = shallow(); + component.instance().uploadFile = jest.fn(); + await component.instance().downloadAndUploadFile({ + localPath: 'https://path.to/file', + }); + expect(component.instance().uploadFile).toHaveBeenCalledWith({ + localPath: 'path/to/downloaded/image', + }); + }); + }); +}); diff --git a/app/components/pasteable_text_input/__snapshots__/custom_text_input.test.js.snap b/app/components/pasteable_text_input/__snapshots__/custom_text_input.test.js.snap new file mode 100644 index 000000000..8a3e61449 --- /dev/null +++ b/app/components/pasteable_text_input/__snapshots__/custom_text_input.test.js.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomTextInput should render custom text input 1`] = ` + + My Text + +`; diff --git a/app/components/pasteable_text_input/__snapshots__/index.test.js.snap b/app/components/pasteable_text_input/__snapshots__/index.test.js.snap new file mode 100644 index 000000000..be4db3f1b --- /dev/null +++ b/app/components/pasteable_text_input/__snapshots__/index.test.js.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PasteableTextInput should render pasteable text input 1`] = ` + + My Text + +`; diff --git a/app/components/pasteable_text_input/custom_text_input.js b/app/components/pasteable_text_input/custom_text_input.js new file mode 100644 index 000000000..9da2e0e13 --- /dev/null +++ b/app/components/pasteable_text_input/custom_text_input.js @@ -0,0 +1,86 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-underscore-dangle */ + +import React from 'react'; +import {TextInput, Text, TouchableWithoutFeedback} from 'react-native'; +import UIManager from 'UIManager'; +import invariant from 'invariant'; +import requireNativeComponent from 'requireNativeComponent'; + +const AndroidTextInput = requireNativeComponent('PasteableTextInputAndroid'); + +// This class is copied from React Native's TextInput +// All credit goes to React Native team +// Source: https://github.com/facebook/react-native/blob/master/Libraries/Components/TextInput/TextInput.js#L1056 +class CustomTextInput extends TextInput { + // Override React Native's TextInput render for Android + _renderAndroid = () => { + const props = Object.assign({}, this.props); + props.style = [this.props.style]; + props.autoCapitalize = UIManager.getViewManagerConfig( + 'AndroidTextInput', + ).Constants.AutoCapitalizationType[props.autoCapitalize || 'sentences']; + let children = this.props.children; + let childCount = 0; + React.Children.forEach(children, () => ++childCount); + invariant( + !(this.props.value && childCount), + 'Cannot specify both value and children.', + ); + if (childCount > 1) { + children = {children}; + } + + if (props.selection && props.selection.end == null) { + props.selection = { + start: props.selection.start, + end: props.selection.start, + }; + } + + const textContainer = ( + + ); + + return ( + + {textContainer} + + ); + }; + + _onPaste = (event) => { + const {nativeEvent} = event; + const {onPaste} = this.props; + return onPaste?.(nativeEvent.error, nativeEvent.data); + } +} + +export default CustomTextInput; diff --git a/app/components/pasteable_text_input/custom_text_input.test.js b/app/components/pasteable_text_input/custom_text_input.test.js new file mode 100644 index 000000000..1327178b8 --- /dev/null +++ b/app/components/pasteable_text_input/custom_text_input.test.js @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {shallow} from 'enzyme'; + +import CustomTextInput from './custom_text_input'; + +describe('CustomTextInput', () => { + test('should render custom text input', () => { + const onPaste = jest.fn(); + const text = 'My Text'; + const component = shallow( + {text} + ); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/app/components/pasteable_text_input/index.js b/app/components/pasteable_text_input/index.js new file mode 100644 index 000000000..84d5c3bb8 --- /dev/null +++ b/app/components/pasteable_text_input/index.js @@ -0,0 +1,53 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import PropTypes from 'prop-types'; +import {TextInput, NativeEventEmitter, NativeModules} from 'react-native'; +import CustomTextInput from './custom_text_input'; + +const {OnPasteEventManager} = NativeModules; +const OnPasteEventEmitter = new NativeEventEmitter(OnPasteEventManager); + +export class PasteableTextInput extends React.Component { + static propTypes = { + ...TextInput.PropTypes, + onPaste: PropTypes.func, + forwardRef: PropTypes.any, + } + + componentDidMount() { + this.subscription = OnPasteEventEmitter.addListener('onPaste', this.onPaste); + } + + componentWillUnmount() { + if (this.subscription) { + this.subscription.remove(); + } + } + + onPaste = (event) => { + const {onPaste} = this.props; + return onPaste?.(null, event); + } + + render() { + const {forwardRef, ...props} = this.props; + + return ( + + ); + } +} + +const WrappedPasteableTextInput = (props, ref) => ( + +); + +export default React.forwardRef(WrappedPasteableTextInput); diff --git a/app/components/pasteable_text_input/index.test.js b/app/components/pasteable_text_input/index.test.js new file mode 100644 index 000000000..101f445cd --- /dev/null +++ b/app/components/pasteable_text_input/index.test.js @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {NativeEventEmitter} from 'react-native'; +import {shallow} from 'enzyme'; + +import {PasteableTextInput} from './index'; + +const nativeEventEmitter = new NativeEventEmitter(); + +describe('PasteableTextInput', () => { + test('should render pasteable text input', () => { + const onPaste = jest.fn(); + const text = 'My Text'; + const component = shallow( + {text} + ); + expect(component).toMatchSnapshot(); + }); + + test('should call onPaste props if native onPaste trigger', () => { + const onPaste = jest.fn(); + const event = {someData: 'data'}; + const text = 'My Text'; + shallow( + {text} + ); + nativeEventEmitter.emit('onPaste', event); + expect(onPaste).toHaveBeenCalledWith(null, event); + }); + + test('should remove onPaste listener when unmount', () => { + const mockRemove = jest.fn(); + const onPaste = jest.fn(); + const text = 'My Text'; + const component = shallow( + {text} + ); + + component.instance().subscription.remove = mockRemove; + component.instance().componentWillUnmount(); + expect(mockRemove).toHaveBeenCalled(); + }); +}); diff --git a/app/components/post_textbox/__snapshots__/post_textbox.test.js.snap b/app/components/post_textbox/__snapshots__/post_textbox.test.js.snap index 3eb315032..4ce8b6f59 100644 --- a/app/components/post_textbox/__snapshots__/post_textbox.test.js.snap +++ b/app/components/post_textbox/__snapshots__/post_textbox.test.js.snap @@ -69,8 +69,7 @@ exports[`PostTextBox should match, full snapshot 1`] = ` ] } > - { expect(wrapper.find(SendButton).prop('disabled')).toBe(true); }); }); + + describe('Paste images', () => { + test('should show error dialog if error occured', () => { + jest.spyOn(Alert, 'alert').mockReturnValue(null); + const wrapper = shallowWithIntl(); + wrapper.find(PasteableTextInput).first().simulate('paste', {error: 'some error'}, []); + expect(Alert.alert).toHaveBeenCalled(); + }); + + test('should show file max warning and not uploading', () => { + jest.spyOn(EventEmitter, 'emit').mockReturnValue(null); + const wrapper = shallowWithIntl(); + wrapper.find(PasteableTextInput).first().simulate('paste', null, [ + { + fileSize: 1000, + fileName: 'fileName.png', + type: 'images/png', + url: 'path/to/image', + }, + { + fileSize: 1000, + fileName: 'fileName.png', + type: 'images/png', + url: 'path/to/image', + }, + { + fileSize: 1000, + fileName: 'fileName.png', + type: 'images/png', + url: 'path/to/image', + }, + { + fileSize: 1000, + fileName: 'fileName.png', + type: 'images/png', + url: 'path/to/image', + }, + { + fileSize: 1000, + fileName: 'fileName.png', + type: 'images/png', + url: 'path/to/image', + }, + { + fileSize: 1000, + fileName: 'fileName.png', + type: 'images/png', + url: 'path/to/image', + }, + ]); + expect(EventEmitter.emit).toHaveBeenCalledWith('fileMaxWarning'); + expect(baseProps.actions.initUploadFiles).not.toHaveBeenCalled(); + }); + + test('should show file size warning and not uploading', () => { + jest.spyOn(EventEmitter, 'emit').mockReturnValue(null); + const wrapper = shallowWithIntl( + + ); + wrapper.find(PasteableTextInput).first().simulate('paste', null, [ + { + fileSize: 51 * 1024 * 1024, + fileName: 'fileName.png', + type: 'images/png', + url: 'path/to/image', + }, + ]); + expect(EventEmitter.emit).toHaveBeenCalledWith('fileSizeWarning', 'File above 50 MB cannot be uploaded: fileName.png'); + expect(baseProps.actions.initUploadFiles).not.toHaveBeenCalled(); + }); + + test('should upload images', () => { + const wrapper = shallowWithIntl(); + wrapper.find(PasteableTextInput).first().simulate('paste', null, [ + { + fileSize: 1000, + fileName: 'fileName.png', + type: 'images/png', + url: 'path/to/image', + }, + ]); + expect(baseProps.actions.initUploadFiles).toHaveBeenCalled(); + }); + }); }); + diff --git a/app/components/post_textbox/post_textbox_base.js b/app/components/post_textbox/post_textbox_base.js index 83b6f3841..f35b133ad 100644 --- a/app/components/post_textbox/post_textbox_base.js +++ b/app/components/post_textbox/post_textbox_base.js @@ -12,7 +12,6 @@ import { NativeModules, Platform, Text, - TextInput, View, } from 'react-native'; import {intlShape} from 'react-intl'; @@ -26,6 +25,7 @@ import Fade from 'app/components/fade'; import FormattedMarkdownText from 'app/components/formatted_markdown_text'; import FormattedText from 'app/components/formatted_text'; import SendButton from 'app/components/send_button'; +import PasteableTextInput from 'app/components/pasteable_text_input'; import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_CONTENT_HEIGHT, MAX_FILE_COUNT} from 'app/constants/post_textbox'; import {NOTIFY_ALL_MEMBERS} from 'app/constants/view'; @@ -635,7 +635,7 @@ export default class PostTextBoxBase extends PureComponent { const {formatMessage} = this.context.intl; const fileSizeWarning = formatMessage({ id: 'file_upload.fileAbove', - defaultMessage: 'File above {max}MB cannot be uploaded: {filename}', + defaultMessage: 'File above {max} cannot be uploaded: {filename}', }, { max: getFormattedFileSize({size: this.props.maxFileSize}), filename, @@ -684,6 +684,50 @@ export default class PostTextBoxBase extends PureComponent { }); }; + showPasteImageErrorDialog = () => { + const {formatMessage} = this.context.intl; + Alert.alert( + formatMessage({ + id: 'mobile.image_paste.error_title', + defaultMessage: 'Paste Image failed', + }), + formatMessage({ + id: 'mobile.image_paste.error_description', + defaultMessage: 'An error occurred while pasting the image. Please try again.', + }), + [ + { + text: formatMessage({ + id: 'mobile.image_paste.error_dismiss', + defaultMessage: 'Dismiss', + }), + }, + ] + ); + } + + handlePasteImages = (error, images) => { + if (error) { + this.showPasteImageErrorDialog(); + return; + } + + const {maxFileSize, files} = this.props; + const availableCount = MAX_FILE_COUNT - files.length; + if (images.length > availableCount) { + this.onShowFileMaxWarning(); + return; + } + + const largeImage = images.find((image) => image.fileSize > maxFileSize); + if (largeImage) { + this.onShowFileSizeWarning(largeImage.fileName); + return; + } + + this.handleUploadFiles(images); + } + renderDeactivatedChannel = () => { const {intl} = this.context; const style = getStyleSheet(this.props.theme); @@ -718,7 +762,7 @@ export default class PostTextBoxBase extends PureComponent { > {this.getAttachmentButton()} - diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index e0fde4738..a0831cd5f 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -79,7 +79,7 @@ "edit_post.editPost": "Edit the post...", "edit_post.save": "Save", "file_attachment.download": "Download", - "file_upload.fileAbove": "File above {max}MB cannot be uploaded: {filename}", + "file_upload.fileAbove": "File above {max} cannot be uploaded: {filename}", "get_post_link_modal.title": "Copy Permalink", "integrations.add": "Add", "intro_messages.anyMember": " Any member can join and read this channel.", diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj index dcc0657fc..08c147f10 100644 --- a/ios/Mattermost.xcodeproj/project.pbxproj +++ b/ios/Mattermost.xcodeproj/project.pbxproj @@ -104,6 +104,9 @@ 84E3264B229834C30055068A /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E325FF229834C30055068A /* Config.swift */; }; 895C9A56B94A45C1BAF568FE /* Entypo.ttf in Resources */ = {isa = PBXBuildFile; fileRef = AC6EB561E1F64C17A69D2FAD /* Entypo.ttf */; }; 8D26455C994F46C39B1392F2 /* libRNSafeArea.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9263CF9B16054263B13EA23B /* libRNSafeArea.a */; }; + 9260D93322F83CA200F51A82 /* OnPasteEventManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 9260D93222F83CA200F51A82 /* OnPasteEventManager.m */; }; + 9260D98222F846D700F51A82 /* UIPasteboard+GetImageInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 9260D98122F846D700F51A82 /* UIPasteboard+GetImageInfo.m */; }; + 92F9F0ED22F702E10035885A /* NSData+MimeType.m in Sources */ = {isa = PBXBuildFile; fileRef = 92F9F0EC22F702E10035885A /* NSData+MimeType.m */; }; 9358B95F95184EE0A4DCE629 /* OpenSans-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D4B1B363C2414DA19C1AC521 /* OpenSans-Bold.ttf */; }; A08D512E7ADC40CCAD055A9E /* OpenSans-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = BC977883E2624E05975CA65B /* OpenSans-Regular.ttf */; }; A9B746D2CFA9CEBFB8AE2B5B /* libPods-Mattermost.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 65FD5EA57EBAE06106094B2F /* libPods-Mattermost.a */; }; @@ -877,7 +880,13 @@ 84E325FF229834C30055068A /* Config.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; 8F0B22D2C9924FAFA7FB681C /* Roboto-LightItalic.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Roboto-LightItalic.ttf"; path = "../assets/fonts/Roboto-LightItalic.ttf"; sourceTree = ""; }; 920B7301B6F84DDD80677487 /* RNGestureHandler.xcodeproj */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = "wrapper.pb-project"; name = RNGestureHandler.xcodeproj; path = "../node_modules/react-native-gesture-handler/ios/RNGestureHandler.xcodeproj"; sourceTree = ""; }; + 9260D93122F83CA200F51A82 /* OnPasteEventManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = OnPasteEventManager.h; path = Mattermost/OnPasteEventManager.h; sourceTree = ""; }; + 9260D93222F83CA200F51A82 /* OnPasteEventManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = OnPasteEventManager.m; path = Mattermost/OnPasteEventManager.m; sourceTree = ""; }; + 9260D98022F846D700F51A82 /* UIPasteboard+GetImageInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "UIPasteboard+GetImageInfo.h"; path = "Mattermost/UIPasteboard+GetImageInfo.h"; sourceTree = ""; }; + 9260D98122F846D700F51A82 /* UIPasteboard+GetImageInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = "UIPasteboard+GetImageInfo.m"; path = "Mattermost/UIPasteboard+GetImageInfo.m"; sourceTree = ""; }; 9263CF9B16054263B13EA23B /* libRNSafeArea.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNSafeArea.a; sourceTree = ""; }; + 92F9F0EB22F702E10035885A /* NSData+MimeType.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "NSData+MimeType.h"; path = "Mattermost/NSData+MimeType.h"; sourceTree = ""; }; + 92F9F0EC22F702E10035885A /* NSData+MimeType.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = "NSData+MimeType.m"; path = "Mattermost/NSData+MimeType.m"; sourceTree = ""; }; A2968536BEC74A4ABBB73BD9 /* RCTYouTube.xcodeproj */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = "wrapper.pb-project"; name = RCTYouTube.xcodeproj; path = "../node_modules/react-native-youtube/RCTYouTube.xcodeproj"; sourceTree = ""; }; A734E00E7E184582A877F2B3 /* Roboto-Thin.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Roboto-Thin.ttf"; path = "../assets/fonts/Roboto-Thin.ttf"; sourceTree = ""; }; AC6EB561E1F64C17A69D2FAD /* Entypo.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Entypo.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Entypo.ttf"; sourceTree = ""; }; @@ -1137,6 +1146,12 @@ 7F292AA51E8ABB1100A450A3 /* splash.png */, 7F5BA34522B99B7B005B05D3 /* Mattermost+RCTUITextView.h */, 7F5BA34622B99B7B005B05D3 /* Mattermost+RCTUITextView.m */, + 9260D98022F846D700F51A82 /* UIPasteboard+GetImageInfo.h */, + 9260D98122F846D700F51A82 /* UIPasteboard+GetImageInfo.m */, + 9260D93122F83CA200F51A82 /* OnPasteEventManager.h */, + 9260D93222F83CA200F51A82 /* OnPasteEventManager.m */, + 92F9F0EB22F702E10035885A /* NSData+MimeType.h */, + 92F9F0EC22F702E10035885A /* NSData+MimeType.m */, ); name = Mattermost; sourceTree = ""; @@ -2669,11 +2684,14 @@ 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, 7F151D3E221B062700FAD8F3 /* RuntimeUtils.swift in Sources */, 7FEB109E1F61019C0039A015 /* UIImage+ImageEffects.m in Sources */, + 92F9F0ED22F702E10035885A /* NSData+MimeType.m in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */, 7FEB10981F6101710039A015 /* BlurAppScreen.m in Sources */, + 9260D98222F846D700F51A82 /* UIPasteboard+GetImageInfo.m in Sources */, 7FEB109D1F61019C0039A015 /* MattermostManaged.m in Sources */, 7F240ACD220D460300637665 /* MattermostBucketModule.m in Sources */, 7F5BA34722B99B7B005B05D3 /* Mattermost+RCTUITextView.m in Sources */, + 9260D93322F83CA200F51A82 /* OnPasteEventManager.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Mattermost/Mattermost+RCTUITextView.m b/ios/Mattermost/Mattermost+RCTUITextView.m index 505798bbc..05fdecddb 100644 --- a/ios/Mattermost/Mattermost+RCTUITextView.m +++ b/ios/Mattermost/Mattermost+RCTUITextView.m @@ -8,9 +8,12 @@ #import "Mattermost+RCTUITextView.h" #import "RCTUITextView.h" +#import +#import "RCTMultilineTextInputView.h" +#import "OnPasteEventManager.h" +#import "UIPasteboard+GetImageInfo.h" @implementation Mattermost_RCTUITextView - @end @implementation RCTUITextView (DisableCopyPaste) @@ -30,7 +33,27 @@ } } + // Allow copy and paste string and image + if (action == @selector(paste:)) { + return [UIPasteboard generalPasteboard].string != nil || [UIPasteboard generalPasteboard].image != nil; + } + return [super canPerformAction:action withSender:sender]; } +-(void)paste:(id)sender { + [super paste:sender]; + + UIPasteboard *pasteboard = [UIPasteboard generalPasteboard]; + if (!pasteboard.hasImages) { + return; + } + + NSArray *images = [pasteboard getCopiedImages]; + [OnPasteEventManager pasteImage:images]; + + // Dismiss contextual menu + [self resignFirstResponder]; +} + @end diff --git a/ios/Mattermost/NSData+MimeType.h b/ios/Mattermost/NSData+MimeType.h new file mode 100644 index 000000000..9e4bfcd61 --- /dev/null +++ b/ios/Mattermost/NSData+MimeType.h @@ -0,0 +1,20 @@ +// +// NSData+MimeType.h +// Mattermost +// +// Created by Tek Min Ewe on 04/08/2019. +// Copyright © 2019 Facebook. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSData (MimeType) + +-(NSString *)mimeType; +-(NSString *)extension; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/Mattermost/NSData+MimeType.m b/ios/Mattermost/NSData+MimeType.m new file mode 100644 index 000000000..b8e31269e --- /dev/null +++ b/ios/Mattermost/NSData+MimeType.m @@ -0,0 +1,59 @@ +// +// NSData+MimeType.m +// Mattermost +// +// Created by Tek Min Ewe on 04/08/2019. +// Copyright © 2019 Facebook. All rights reserved. +// + +#import "NSData+MimeType.h" + +@implementation NSData (MimeType) + +-(NSString *)mimeType { + uint8_t c; + [self getBytes:&c length:1]; + + switch (c) { + case 0xFF: + return @"image/jpeg"; + break; + case 0x89: + return @"image/png"; + break; + case 0x47: + return @"image/gif"; + break; + case 0x49: + case 0x4D: + return @"image/tiff"; + break; + default: + return @""; + } +} + +-(NSString *)extension { + uint8_t c; + [self getBytes:&c length:1]; + + switch (c) { + case 0xFF: + return @".jpeg"; + break; + case 0x89: + return @".png"; + break; + case 0x47: + return @".gif"; + break; + case 0x49: + case 0x4D: + return @".tiff"; + break; + default: + return @""; + } +} + +@end diff --git a/ios/Mattermost/OnPasteEventManager.h b/ios/Mattermost/OnPasteEventManager.h new file mode 100644 index 000000000..76deefe19 --- /dev/null +++ b/ios/Mattermost/OnPasteEventManager.h @@ -0,0 +1,20 @@ +// +// PasteEventManager.h +// Mattermost +// +// Created by Tek Min Ewe on 05/08/2019. +// Copyright © 2019 Facebook. All rights reserved. +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface OnPasteEventManager : RCTEventEmitter + ++(void)pasteImage:(NSArray *)data; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/Mattermost/OnPasteEventManager.m b/ios/Mattermost/OnPasteEventManager.m new file mode 100644 index 000000000..0e359bf79 --- /dev/null +++ b/ios/Mattermost/OnPasteEventManager.m @@ -0,0 +1,47 @@ +// +// PasteEventManager.m +// Mattermost +// +// Created by Tek Min Ewe on 05/08/2019. +// Copyright © 2019 Facebook. All rights reserved. +// + +#import "OnPasteEventManager.h" + +@implementation OnPasteEventManager { + bool hasListeners; +} + +RCT_EXPORT_MODULE(); + +-(void)startObserving { + hasListeners = YES; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onPaste:) name:@"onPaste" object:nil]; +} + +-(void)stopObserving { + hasListeners = NO; + + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +-(NSArray*)supportedEvents { + return @[@"onPaste"]; +} + +-(void)onPaste:(NSNotification *)data { + if (!hasListeners) { + return; + } + + [self sendEventWithName:@"onPaste" body:data.userInfo[@"data"]]; +} + ++(void)pasteImage:(NSArray *)data { + [[NSNotificationCenter defaultCenter] postNotificationName:@"onPaste" object:data userInfo:@{ + @"data": data + }]; +} + +@end diff --git a/ios/Mattermost/UIPasteboard+GetImageInfo.h b/ios/Mattermost/UIPasteboard+GetImageInfo.h new file mode 100644 index 000000000..74e3f79b9 --- /dev/null +++ b/ios/Mattermost/UIPasteboard+GetImageInfo.h @@ -0,0 +1,19 @@ +// +// UIPasteboard+GetImageInfo.h +// Mattermost +// +// Created by Tek Min Ewe on 05/08/2019. +// Copyright © 2019 Facebook. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIPasteboard (GetImageInfo) + +-(NSArray *)getCopiedImages; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/Mattermost/UIPasteboard+GetImageInfo.m b/ios/Mattermost/UIPasteboard+GetImageInfo.m new file mode 100644 index 000000000..b13816f53 --- /dev/null +++ b/ios/Mattermost/UIPasteboard+GetImageInfo.m @@ -0,0 +1,52 @@ +// +// UIPasteboard+GetImageInfo.m +// Mattermost +// +// Created by Tek Min Ewe on 05/08/2019. +// Copyright © 2019 Facebook. All rights reserved. +// + +#import "UIPasteboard+GetImageInfo.h" +#import "NSData+MimeType.h" + +@implementation UIPasteboard (GetImageInfo) + +-(NSArray *)getCopiedImages { + NSArray *images = self.images; + NSMutableArray *imageInfos = [[NSMutableArray alloc] init]; + for (int i = 0; i < images.count; i++) { + UIImage *image = images[i]; + NSString *uri = self.string; + NSArray *types = self.pasteboardTypes; + + NSData *imageData; + if ([types[0] isEqual:@"public.jpeg"]) { + imageData = UIImageJPEGRepresentation(image, 1.0); + } else if ([types[0] isEqual:@"public.png"]) { + imageData = UIImagePNGRepresentation(image); + } else { + imageData = [self dataForPasteboardType:types[0]]; + } + NSString *extension = [imageData extension]; + NSString *mimeType = [imageData mimeType]; + + NSString *tempFilename = [NSString stringWithFormat:@"%@%@", [[NSProcessInfo processInfo] globallyUniqueString], extension]; + NSURL *tempFileURL = [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:tempFilename]]; + BOOL success = [imageData writeToURL:tempFileURL atomically:YES]; + + if (success) { + uri = tempFileURL.absoluteString; + } + + [imageInfos addObject:@{ + @"fileName": tempFilename, + @"fileSize": @([imageData length]), + @"type": mimeType, + @"uri": uri, + }]; + } + + return imageInfos; +} + +@end