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:
parent
da9be433e3
commit
0d1fd78263
28 changed files with 1121 additions and 15 deletions
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
public interface RNEditTextOnPasteListener {
|
||||
void onPaste(Uri itemUri);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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>
|
||||
`;
|
||||
86
app/components/pasteable_text_input/custom_text_input.js
Normal file
86
app/components/pasteable_text_input/custom_text_input.js
Normal 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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
53
app/components/pasteable_text_input/index.js
Normal file
53
app/components/pasteable_text_input/index.js
Normal 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);
|
||||
44
app/components/pasteable_text_input/index.test.js
Normal file
44
app/components/pasteable_text_input/index.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
ios/Mattermost/NSData+MimeType.h
Normal file
20
ios/Mattermost/NSData+MimeType.h
Normal 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
|
||||
59
ios/Mattermost/NSData+MimeType.m
Normal file
59
ios/Mattermost/NSData+MimeType.m
Normal 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
|
||||
20
ios/Mattermost/OnPasteEventManager.h
Normal file
20
ios/Mattermost/OnPasteEventManager.h
Normal 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
|
||||
47
ios/Mattermost/OnPasteEventManager.m
Normal file
47
ios/Mattermost/OnPasteEventManager.m
Normal 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
|
||||
19
ios/Mattermost/UIPasteboard+GetImageInfo.h
Normal file
19
ios/Mattermost/UIPasteboard+GetImageInfo.h
Normal 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
|
||||
52
ios/Mattermost/UIPasteboard+GetImageInfo.m
Normal file
52
ios/Mattermost/UIPasteboard+GetImageInfo.m
Normal 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
|
||||
Loading…
Reference in a new issue