MM-9599 Paste image from clipboard (#3084)

* Show image paste menu

* Get pasted image

* Add more info for file

* Add custom text input and add extension

* Dismiss contextual menu after paste image

* Group image info together

* Add max file check

* Fix max file size text

* Add PropTypes

* Add support for gif and tiff

* add onchange null check

* Use onPaste event

* Move get image info logic

* Clean up listener when no observer

* Add android upload

* Copy file from google docs

* Clean up file after upload

* Prevent text pasted in textbox if it's content uri

* Rename paste file thread

* Move on paste listener logic

* Remove the redundant data in ios

* Get realpath of item

* Clean up

* Only download for image

* Rename to custom text input

* Update RNPasteableEditTextOnPasteListener.java

* Handle for download image failed

* Fix eslint

* Fix test

* Allow multiple images to be pasted

* Remove additional null check

* Add managed control for Android

* Disable only copy, cut and paste

* Accept image in Android edit text

* Add comment for custom text input

* Do not upload when more than max file

* Stop uplaod when exceed file size

* Fix crash when clip data is null

* Return error to JS

* Move download file logic

* Remove console

* Add some tests

* Add test for handleUploadImages

* Add test for file_upload_item

* Use ImageCacheManager to cache remote images

* Fix crashes from one note

* Remove commented code

* Update test
This commit is contained in:
Ewe Tek Min 2019-09-17 07:10:14 +08:00 committed by Elias Nahum
parent da9be433e3
commit 0d1fd78263
28 changed files with 1121 additions and 15 deletions

View file

@ -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()
);
}

View file

@ -0,0 +1,7 @@
package com.mattermost.rnbeta;
import android.net.Uri;
public interface RNEditTextOnPasteListener {
void onPaste(Uri itemUri);
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

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

View file

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

View file

@ -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<String, Object> getExportedCustomBubblingEventTypeConstants() {
Map map = super.getExportedViewConstants();
map.put("onPaste", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onPaste")));
return map;
}
}

View file

@ -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<NativeModule> createNativeModules(@Nonnull ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Nonnull
@Override
public List<ViewManager> createViewManagers(@Nonnull ReactApplicationContext reactContext) {
return Arrays.asList(
new RNPasteableTextInputManager()
);
}
}

View file

@ -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 = {

View file

@ -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(<FileUploadItem {...props}/>);
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(<FileUploadItem {...props}/>);
component.instance().uploadFile = jest.fn();
await component.instance().downloadAndUploadFile({
localPath: 'https://path.to/file',
});
expect(component.instance().uploadFile).toHaveBeenCalledWith({
localPath: 'path/to/downloaded/image',
});
});
});
});

View file

@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CustomTextInput should render custom text input 1`] = `
<TextInput
allowFontScaling={true}
onPaste={[MockFunction]}
rejectResponderTermination={true}
underlineColorAndroid="transparent"
>
My Text
</TextInput>
`;

View file

@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PasteableTextInput should render pasteable text input 1`] = `
<TextInput
allowFontScaling={true}
onPaste={[MockFunction]}
rejectResponderTermination={true}
underlineColorAndroid="transparent"
>
My Text
</TextInput>
`;

View file

@ -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 = <Text>{children}</Text>;
}
if (props.selection && props.selection.end == null) {
props.selection = {
start: props.selection.start,
end: props.selection.start,
};
}
const textContainer = (
<AndroidTextInput
ref={this._setNativeRef}
{...props}
mostRecentEventCount={0}
onFocus={this._onFocus}
onBlur={this._onBlur}
onChange={this._onChange}
onSelectionChange={this._onSelectionChange}
onTextInput={this._onTextInput}
text={this._getText()}
// eslint-disable-next-line react/no-children-prop
children={children}
disableFullscreenUI={this.props.disableFullscreenUI}
textBreakStrategy={this.props.textBreakStrategy}
onScroll={this._onScroll}
onPaste={this._onPaste}
/>
);
return (
<TouchableWithoutFeedback
onLayout={props.onLayout}
onPress={this._onPress}
accessible={this.props.accessible}
accessibilityLabel={this.props.accessibilityLabel}
accessibilityRole={this.props.accessibilityRole}
accessibilityStates={this.props.accessibilityStates}
nativeID={this.props.nativeID}
testID={this.props.testID}
>
{textContainer}
</TouchableWithoutFeedback>
);
};
_onPaste = (event) => {
const {nativeEvent} = event;
const {onPaste} = this.props;
return onPaste?.(nativeEvent.error, nativeEvent.data);
}
}
export default CustomTextInput;

View file

@ -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(
<CustomTextInput onPaste={onPaste}>{text}</CustomTextInput>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -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 (
<CustomTextInput
{...props}
ref={forwardRef}
/>
);
}
}
const WrappedPasteableTextInput = (props, ref) => (
<PasteableTextInput
{...props}
forwardRef={ref}
/>
);
export default React.forwardRef(WrappedPasteableTextInput);

View file

@ -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(
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>
);
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(
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>
);
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(
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>
);
component.instance().subscription.remove = mockRemove;
component.instance().componentWillUnmount();
expect(mockRemove).toHaveBeenCalled();
});
});

View file

@ -69,8 +69,7 @@ exports[`PostTextBox should match, full snapshot 1`] = `
]
}
>
<TextInput
allowFontScaling={true}
<ForwardRef(WrappedPasteableTextInput)
blurOnSubmit={false}
disableFullscreenUI={true}
editable={true}
@ -79,10 +78,10 @@ exports[`PostTextBox should match, full snapshot 1`] = `
multiline={true}
onChangeText={[Function]}
onEndEditing={[Function]}
onPaste={[Function]}
onSelectionChange={[Function]}
placeholder="Write to Test Channel"
placeholderTextColor="rgba(61,60,64,0.5)"
rejectResponderTermination={true}
style={
Object {
"color": "#3d3c40",

View file

@ -10,6 +10,8 @@ import Preferences from 'mattermost-redux/constants/preferences';
import Fade from 'app/components/fade';
import SendButton from 'app/components/send_button';
import PasteableTextInput from 'app/components/pasteable_text_input';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import PostTextbox from './post_textbox.ios';
@ -336,4 +338,92 @@ describe('PostTextBox', () => {
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(<PostTextbox {...baseProps}/>);
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(<PostTextbox {...baseProps}/>);
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(
<PostTextbox
{...baseProps}
maxFileSize={50 * 1024 * 1024}
/>
);
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(<PostTextbox {...baseProps}/>);
wrapper.find(PasteableTextInput).first().simulate('paste', null, [
{
fileSize: 1000,
fileName: 'fileName.png',
type: 'images/png',
url: 'path/to/image',
},
]);
expect(baseProps.actions.initUploadFiles).toHaveBeenCalled();
});
});
});

View file

@ -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()}
<View style={this.getInputContainerStyle()}>
<TextInput
<PasteableTextInput
ref={this.input}
value={textValue}
onChangeText={this.handleTextChange}
@ -733,6 +777,7 @@ export default class PostTextBoxBase extends PureComponent {
onEndEditing={this.handleEndEditing}
disableFullscreenUI={true}
editable={!channelIsReadOnly}
onPaste={this.handlePasteImages}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
/>
<Fade visible={this.isSendButtonVisible()}>

View file

@ -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.",

View file

@ -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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
9260D93122F83CA200F51A82 /* OnPasteEventManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = OnPasteEventManager.h; path = Mattermost/OnPasteEventManager.h; sourceTree = "<group>"; };
9260D93222F83CA200F51A82 /* OnPasteEventManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = OnPasteEventManager.m; path = Mattermost/OnPasteEventManager.m; sourceTree = "<group>"; };
9260D98022F846D700F51A82 /* UIPasteboard+GetImageInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "UIPasteboard+GetImageInfo.h"; path = "Mattermost/UIPasteboard+GetImageInfo.h"; sourceTree = "<group>"; };
9260D98122F846D700F51A82 /* UIPasteboard+GetImageInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = "UIPasteboard+GetImageInfo.m"; path = "Mattermost/UIPasteboard+GetImageInfo.m"; sourceTree = "<group>"; };
9263CF9B16054263B13EA23B /* libRNSafeArea.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNSafeArea.a; sourceTree = "<group>"; };
92F9F0EB22F702E10035885A /* NSData+MimeType.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "NSData+MimeType.h"; path = "Mattermost/NSData+MimeType.h"; sourceTree = "<group>"; };
92F9F0EC22F702E10035885A /* NSData+MimeType.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = "NSData+MimeType.m"; path = "Mattermost/NSData+MimeType.m"; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
@ -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 = "<group>";
@ -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;
};

View file

@ -8,9 +8,12 @@
#import "Mattermost+RCTUITextView.h"
#import "RCTUITextView.h"
#import <React/RCTUtils.h>
#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<NSDictionary *> *images = [pasteboard getCopiedImages];
[OnPasteEventManager pasteImage:images];
// Dismiss contextual menu
[self resignFirstResponder];
}
@end

View file

@ -0,0 +1,20 @@
//
// NSData+MimeType.h
// Mattermost
//
// Created by Tek Min Ewe on 04/08/2019.
// Copyright © 2019 Facebook. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface NSData (MimeType)
-(NSString *)mimeType;
-(NSString *)extension;
@end
NS_ASSUME_NONNULL_END

View file

@ -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

View file

@ -0,0 +1,20 @@
//
// PasteEventManager.h
// Mattermost
//
// Created by Tek Min Ewe on 05/08/2019.
// Copyright © 2019 Facebook. All rights reserved.
//
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
NS_ASSUME_NONNULL_BEGIN
@interface OnPasteEventManager : RCTEventEmitter<RCTBridgeModule>
+(void)pasteImage:(NSArray<NSDictionary *> *)data;
@end
NS_ASSUME_NONNULL_END

View file

@ -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<NSString *>*)supportedEvents {
return @[@"onPaste"];
}
-(void)onPaste:(NSNotification *)data {
if (!hasListeners) {
return;
}
[self sendEventWithName:@"onPaste" body:data.userInfo[@"data"]];
}
+(void)pasteImage:(NSArray<NSDictionary *> *)data {
[[NSNotificationCenter defaultCenter] postNotificationName:@"onPaste" object:data userInfo:@{
@"data": data
}];
}
@end

View file

@ -0,0 +1,19 @@
//
// UIPasteboard+GetImageInfo.h
// Mattermost
//
// Created by Tek Min Ewe on 05/08/2019.
// Copyright © 2019 Facebook. All rights reserved.
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface UIPasteboard (GetImageInfo)
-(NSArray<NSDictionary *> *)getCopiedImages;
@end
NS_ASSUME_NONNULL_END

View file

@ -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<NSDictionary *> *)getCopiedImages {
NSArray<UIImage *> *images = self.images;
NSMutableArray<NSDictionary *> *imageInfos = [[NSMutableArray alloc] init];
for (int i = 0; i < images.count; i++) {
UIImage *image = images[i];
NSString *uri = self.string;
NSArray<NSString *> *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