From 5770a9b2349fa849bbbeb43c34eb7552249a270f Mon Sep 17 00:00:00 2001 From: Nikolaos Karaolidis Date: Sat, 29 Jul 2023 22:43:58 +0300 Subject: [PATCH] Add pasting from clipboard Signed-off-by: Nikolaos Karaolidis --- ...ct-native-clipboard+clipboard+1.11.2.patch | 273 +++++++++++++++++- src/components/floatingActionButton.tsx | 121 ++++++-- .../storageLocationChangeDialog.tsx | 3 - src/screens/memeView.tsx | 28 +- src/screens/settings.tsx | 13 +- src/utilities/clipboard.ts | 11 + src/utilities/filesystem.ts | 2 + src/utilities/index.ts | 1 + 8 files changed, 388 insertions(+), 64 deletions(-) create mode 100644 src/utilities/clipboard.ts diff --git a/patches/@react-native-clipboard+clipboard+1.11.2.patch b/patches/@react-native-clipboard+clipboard+1.11.2.patch index a9ce42a..a77ccf1 100644 --- a/patches/@react-native-clipboard+clipboard+1.11.2.patch +++ b/patches/@react-native-clipboard+clipboard+1.11.2.patch @@ -1,11 +1,134 @@ diff --git a/node_modules/@react-native-clipboard/clipboard/android/src/main/java/com/reactnativecommunity/clipboard/ClipboardModule.java b/node_modules/@react-native-clipboard/clipboard/android/src/main/java/com/reactnativecommunity/clipboard/ClipboardModule.java -index 048ebe5..8afa5b2 100644 +index 048ebe5..01fa3ad 100644 --- a/node_modules/@react-native-clipboard/clipboard/android/src/main/java/com/reactnativecommunity/clipboard/ClipboardModule.java +++ b/node_modules/@react-native-clipboard/clipboard/android/src/main/java/com/reactnativecommunity/clipboard/ClipboardModule.java -@@ -156,6 +156,17 @@ public class ClipboardModule extends ReactContextBaseJavaModule { +@@ -24,6 +24,7 @@ import com.facebook.react.bridge.ReactApplicationContext; + import com.facebook.react.bridge.ReactContextBaseJavaModule; + import com.facebook.react.bridge.ReactMethod; + import com.facebook.react.bridge.Promise; ++import com.facebook.react.bridge.WritableNativeMap; + import com.facebook.react.module.annotations.ReactModule; + import com.facebook.react.modules.core.DeviceEventManagerModule; + +@@ -70,9 +71,9 @@ public class ClipboardModule extends ReactContextBaseJavaModule { + ClipData clipData = clipboard.getPrimaryClip(); + if (clipData != null && clipData.getItemCount() >= 1) { + ClipData.Item firstItem = clipboard.getPrimaryClip().getItemAt(0); +- promise.resolve("" + firstItem.getText()); ++ promise.resolve(firstItem.getText()); + } else { +- promise.resolve(""); ++ promise.resolve(null); + } + } catch (Exception e) { + promise.reject(e); +@@ -95,34 +96,37 @@ public class ClipboardModule extends ReactContextBaseJavaModule { + try { + ClipboardManager clipboard = getClipboardService(); + ClipData clipData = clipboard.getPrimaryClip(); +- promise.resolve(clipData != null && clipData.getItemCount() >= 1); ++ if (clipData != null && clipData.getItemCount() >= 1) { ++ ClipData.Item firstItem = clipboard.getPrimaryClip().getItemAt(0); ++ promise.resolve(firstItem.getText() != null); ++ } else { ++ promise.resolve(false); ++ } + } catch (Exception e) { + promise.reject(e); } } - + + @ReactMethod +- public void getImage(Promise promise){ ++ public void getImage(Promise promise) { + ClipboardManager clipboardManager = getClipboardService(); +- if (!(clipboardManager.hasPrimaryClip())){ +- promise.resolve(""); +- } +- else if (clipboardManager.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)){ +- promise.resolve(""); +- } +- else { ++ if (!(clipboardManager.hasPrimaryClip())) { ++ promise.resolve(null); ++ } else if (clipboardManager.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) { ++ promise.resolve(null); ++ } else { + ClipData clipData = clipboardManager.getPrimaryClip(); +- if(clipData != null){ ++ if (clipData != null) { + ClipData.Item item = clipData.getItemAt(0); + Uri pasteUri = item.getUri(); +- if (pasteUri != null){ ++ if (pasteUri != null) { + ContentResolver cr = reactContext.getContentResolver(); + String mimeType = cr.getType(pasteUri); +- if (mimeType != null){ ++ if (mimeType != null) { + try { + Bitmap bitmap = MediaStore.Images.Media.getBitmap(cr, pasteUri); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); +- switch(mimeType){ ++ switch (mimeType) { + case MIMETYPE_JPEG: + case MIMETYPE_JPG: + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream); +@@ -133,7 +137,7 @@ public class ClipboardModule extends ReactContextBaseJavaModule { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream); + break; + case MIMETYPE_WEBP: +- if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q){ ++ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { + bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSLESS, 100, outputStream); + break; + } +@@ -156,6 +160,77 @@ public class ClipboardModule extends ReactContextBaseJavaModule { + } + } + ++ @ReactMethod ++ public void hasImage(Promise promise) { ++ try { ++ ClipboardManager clipboard = getClipboardService(); ++ ClipData clipData = clipboard.getPrimaryClip(); ++ if (clipData != null && clipData.getItemCount() >= 1) { ++ ClipData.Item firstItem = clipboard.getPrimaryClip().getItemAt(0); ++ Uri pasteUri = firstItem.getUri(); ++ if (pasteUri != null) { ++ ContentResolver cr = reactContext.getContentResolver(); ++ String mimeType = cr.getType(pasteUri); ++ if (mimeType != null) { ++ promise.resolve(mimeType.startsWith("image/")); ++ return; ++ } ++ } ++ } ++ promise.resolve(false); ++ } catch (Exception e) { ++ promise.reject(e); ++ } ++ } ++ ++ @ReactMethod ++ public void getURI(Promise promise) { ++ try { ++ ClipboardManager clipboard = getClipboardService(); ++ ClipData clipData = clipboard.getPrimaryClip(); ++ if (clipData != null && clipData.getItemCount() >= 1) { ++ ClipData.Item firstItem = clipboard.getPrimaryClip().getItemAt(0); ++ Uri uri = firstItem.getUri(); ++ if (uri != null) { ++ promise.resolve(uri.toString()); ++ } else { ++ promise.resolve(null); ++ } ++ } ++ promise.resolve(null); ++ } catch (Exception e) { ++ promise.reject(e); ++ } ++ } ++ + @ReactMethod + public void setURI(String uri) { + try { @@ -16,20 +139,69 @@ index 048ebe5..8afa5b2 100644 + e.printStackTrace(); + } + } ++ ++ @ReactMethod ++ public void hasURI(Promise promise) { ++ try { ++ ClipboardManager clipboard = getClipboardService(); ++ ClipData clipData = clipboard.getPrimaryClip(); ++ if (clipData != null && clipData.getItemCount() >= 1) { ++ ClipData.Item firstItem = clipboard.getPrimaryClip().getItemAt(0); ++ Uri pasteUri = firstItem.getUri(); ++ promise.resolve(pasteUri != null); ++ } else { ++ promise.resolve(false); ++ } ++ } catch (Exception e) { ++ promise.reject(e); ++ } ++ } + @ReactMethod public void setListener() { try { +@@ -164,8 +239,8 @@ public class ClipboardModule extends ReactContextBaseJavaModule { + @Override + public void onPrimaryClipChanged() { + reactContext +- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) +- .emit(CLIPBOARD_TEXT_CHANGED, null); ++ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) ++ .emit(CLIPBOARD_TEXT_CHANGED, null); + } + }; + clipboard.addPrimaryClipChangedListener(listener); +@@ -176,8 +251,8 @@ public class ClipboardModule extends ReactContextBaseJavaModule { + + @ReactMethod + public void removeListener() { +- if(listener != null){ +- try{ ++ if (listener != null) { ++ try { + ClipboardManager clipboard = getClipboardService(); + clipboard.removePrimaryClipChangedListener(listener); + } catch (Exception e) { diff --git a/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.d.ts b/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.d.ts -index a3e4abd..904a199 100644 +index a3e4abd..9fc11e6 100644 --- a/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.d.ts +++ b/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.d.ts -@@ -81,6 +81,17 @@ export declare const Clipboard: { +@@ -81,6 +81,38 @@ export declare const Clipboard: { * @param the content to be stored in the clipboard. */ setStrings(content: string[]): void; + /** + * (Android Only) ++ * Get content of URI type. You can use following code to get clipboard content ++ * ```javascript ++ * async _getContent() { ++ * var content = await Clipboard.getURI(); ++ * } ++ * ``` ++ */ ++ getURI(): Promise; ++ /** ++ * (Android Only) + * Set content of URI type. You can use following code to set clipboard content + * ```javascript + * _setContent() { @@ -39,19 +211,90 @@ index a3e4abd..904a199 100644 + * @param the content to be stored in the clipboard. + */ + setURI(content: string): void; ++ /** ++ * (Android Only) ++ * Returns whether the clipboard has a URI or is empty. ++ * This method returns a `Promise`, so you can use following code to check clipboard content ++ * ```javascript ++ * async _hasContent() { ++ * var hasContent = await Clipboard.hasURI(); ++ * } ++ * ``` ++ */ ++ hasURI(): Promise; /** * Returns whether the clipboard has content or is empty. * This method returns a `Promise`, so you can use following code to get clipboard content +@@ -90,7 +122,7 @@ export declare const Clipboard: { + * } + * ``` + */ +- hasString(): any; ++ hasString(): Promise; + /** + * Returns whether the clipboard has an image or is empty. + * This method returns a `Promise`, so you can use following code to check clipboard content +@@ -100,7 +132,7 @@ export declare const Clipboard: { + * } + * ``` + */ +- hasImage(): any; ++ hasImage(): Promise; + /** + * (iOS Only) + * Returns whether the clipboard has a URL content. Can check +@@ -112,7 +144,7 @@ export declare const Clipboard: { + * } + * ``` + */ +- hasURL(): any; ++ hasURL(): Promise; + /** + * (iOS 14+ Only) + * Returns whether the clipboard has a Number(UIPasteboardDetectionPatternNumber) content. Can check +@@ -124,7 +156,7 @@ export declare const Clipboard: { + * } + * ``` + */ +- hasNumber(): any; ++ hasNumber(): Promise; + /** + * (iOS 14+ Only) + * Returns whether the clipboard has a WebURL(UIPasteboardDetectionPatternProbableWebURL) content. Can check +@@ -136,7 +168,7 @@ export declare const Clipboard: { + * } + * ``` + */ +- hasWebURL(): any; ++ hasWebURL(): Promise; + /** + * (iOS and Android Only) + * Adds a listener to get notifications when the clipboard has changed. diff --git a/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.js b/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.js -index 67b7237..0a74329 100644 +index 67b7237..df3bff6 100644 --- a/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.js +++ b/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.js -@@ -123,6 +123,22 @@ exports.Clipboard = { +@@ -123,6 +123,53 @@ exports.Clipboard = { setStrings: function (content) { NativeClipboard_1.default.setStrings(content); }, + /** + * (Android Only) ++ * Get content of URI type. You can use following code to get clipboard content ++ * ```javascript ++ * async _getContent() { ++ * var content = await Clipboard.getURI(); ++ * } ++ * ``` ++ */ ++ getURI: function () { ++ if (react_native_1.Platform.OS !== 'android') { ++ return; ++ } ++ return NativeClipboard_1.default.getURI(); ++ }, ++ /** ++ * (Android Only) + * Set content of URI type. You can use following code to set clipboard content + * ```javascript + * _setContent() { @@ -65,6 +308,22 @@ index 67b7237..0a74329 100644 + return; + } + return NativeClipboard_1.default.setURI(content); ++ }, ++ /** ++ * (Android Only) ++ * Returns whether the clipboard has a URI or is empty. ++ * This method returns a `Promise`, so you can use following code to check clipboard content ++ * ```javascript ++ * async _hasContent() { ++ * var hasContent = await Clipboard.hasURI(); ++ * } ++ * ``` ++ */ ++ hasURI: function () { ++ if (react_native_1.Platform.OS !== 'android') { ++ return; ++ } ++ return NativeClipboard_1.default.hasURI(); + }, /** * Returns whether the clipboard has content or is empty. diff --git a/src/components/floatingActionButton.tsx b/src/components/floatingActionButton.tsx index 432cbdd..fe176ec 100644 --- a/src/components/floatingActionButton.tsx +++ b/src/components/floatingActionButton.tsx @@ -1,12 +1,18 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Keyboard, StyleSheet } from 'react-native'; -import { FAB } from 'react-native-paper'; +import { FAB, Snackbar } from 'react-native-paper'; import { ParamListBase, useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { pick } from 'react-native-document-picker'; import { useDeviceOrientation } from '@react-native-community/hooks'; +import Clipboard from '@react-native-clipboard/clipboard'; import { documentPickerResponseToAddMemeFile, ROUTE } from '../types'; -import { allowedMimeTypes, noOp } from '../utilities'; +import { + allowedMimeTypes, + getFilenameFromUri, + guessMimeType, + noOp, +} from '../utilities'; const floatingActionButtonStyles = StyleSheet.create({ fab: { @@ -17,6 +23,9 @@ const floatingActionButtonStyles = StyleSheet.create({ paddingBottom: 40, paddingRight: 12, }, + snackbar: { + marginBottom: 90, + }, }); const FloatingActionButton = ({ visible = true }: { visible?: boolean }) => { @@ -27,6 +36,8 @@ const FloatingActionButton = ({ visible = true }: { visible?: boolean }) => { const [state, setState] = useState(false); const [keyboardOpen, setKeyboardOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(); + useEffect(() => { const keyboardDidShowListener = Keyboard.addListener( 'keyboardDidShow', @@ -43,35 +54,85 @@ const FloatingActionButton = ({ visible = true }: { visible?: boolean }) => { }; }, []); - return ( - { + const response = await pick({ + type: allowedMimeTypes, + allowMultiSelection: true, + }).catch(noOp); + if (!response) return; + const files = documentPickerResponseToAddMemeFile(response); + navigate(ROUTE.ADD_MEME, { files }); + }, [navigate]); + + const handleAddTag = useCallback(() => { + navigate(ROUTE.ADD_TAG); + }, [navigate]); + + const handlePaste = useCallback(async () => { + const uri = await Clipboard.getURI(); + if (!uri) { + setSnackbarMessage('Clipboard does not contain a URI.'); + return; + } + const mimeType = guessMimeType(uri); + if (!mimeType) { + setSnackbarMessage('Unsupported MIME type.'); + return; + } + navigate(ROUTE.ADD_MEME, { + files: [ { - icon: 'tag', - label: 'Tag', - onPress: () => navigate(ROUTE.ADD_TAG), + uri: uri, + filename: getFilenameFromUri(uri), + type: mimeType, }, - ]} - onStateChange={({ open }) => setState(open)} - onPress={async () => { - if (!state) return; - const response = await pick({ - type: allowedMimeTypes, - allowMultiSelection: true, - }).catch(noOp); - if (!response) return; - const files = documentPickerResponseToAddMemeFile(response); - navigate(ROUTE.ADD_MEME, { files }); - }} - style={ - orientation === 'portrait' - ? floatingActionButtonStyles.fab - : floatingActionButtonStyles.fabLandscape - } - /> + ], + }); + }, [navigate]); + + return ( + <> + setState(open)} + style={ + orientation === 'portrait' + ? floatingActionButtonStyles.fab + : floatingActionButtonStyles.fabLandscape + } + /> + setSnackbarMessage(undefined)} + style={floatingActionButtonStyles.snackbar} + action={{ + label: 'Dismiss', + // eslint-disable-next-line unicorn/no-useless-undefined + onPress: () => setSnackbarMessage(undefined), + }}> + {snackbarMessage} + + ); }; diff --git a/src/components/storageLocationChangeDialog.tsx b/src/components/storageLocationChangeDialog.tsx index 4b35d2b..f614209 100644 --- a/src/components/storageLocationChangeDialog.tsx +++ b/src/components/storageLocationChangeDialog.tsx @@ -16,12 +16,10 @@ const storageLocationChangeDialogStyles = StyleSheet.create({ const StorageLocationChangeDialog = ({ visible, setVisible, - setSnackbarVisible, setSnackbarMessage, }: { visible: boolean; setVisible: (visible: boolean) => void; - setSnackbarVisible: (visible: boolean) => void; setSnackbarMessage: (message: string) => void; }) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -45,7 +43,6 @@ const StorageLocationChangeDialog = ({ if (isPermissionForPath(storageUri, newStorageUri)) { setSnackbarMessage('Folder already selected.'); - setSnackbarVisible(true); setVisible(false); return; } diff --git a/src/screens/memeView.tsx b/src/screens/memeView.tsx index 2f1e135..440a273 100644 --- a/src/screens/memeView.tsx +++ b/src/screens/memeView.tsx @@ -59,8 +59,7 @@ const MemeView = ({ const { ids } = route.params; const [index, setIndex] = useState(route.params.index); - const [snackbarVisible, setSnackbarVisible] = useState(false); - const [snackbarMessage, setSnackbarMessage] = useState(''); + const [snackbarMessage, setSnackbarMessage] = useState(); const flashListRef = useRef>(null); @@ -101,24 +100,17 @@ const MemeView = ({ { - shareMeme(realm, storageUri, memes[index]).catch(() => { - setSnackbarMessage('Failed to share meme!'); - setSnackbarVisible(true); - }); + shareMeme(realm, storageUri, memes[index]).catch(() => + setSnackbarMessage('Failed to share meme!'), + ); }} /> { await copyMeme(realm, storageUri, memes[index]) - .then(() => { - setSnackbarMessage('Meme copied!'); - setSnackbarVisible(true); - }) - .catch(() => { - setSnackbarMessage('Failed to copy meme!'); - setSnackbarVisible(true); - }); + .then(() => setSnackbarMessage('Meme copied!')) + .catch(() => setSnackbarMessage('Failed to copy meme!')); }} /> setSnackbarVisible(false)} + visible={!!snackbarMessage} + // eslint-disable-next-line unicorn/no-useless-undefined + onDismiss={() => setSnackbarMessage(undefined)} style={memeViewStyles.snackbar} action={{ label: 'Dismiss', - onPress: () => setSnackbarVisible(false), + // eslint-disable-next-line unicorn/no-useless-undefined + onPress: () => setSnackbarMessage(undefined), }}> {snackbarMessage} diff --git a/src/screens/settings.tsx b/src/screens/settings.tsx index bac6839..3391bde 100644 --- a/src/screens/settings.tsx +++ b/src/screens/settings.tsx @@ -60,8 +60,7 @@ const Settings = () => { const dispatch = useDispatch(); const realm = useRealm(); - const [snackbarVisible, setSnackbarVisible] = useState(false); - const [snackbarMessage, setSnackbarMessage] = useState(''); + const [snackbarMessage, setSnackbarMessage] = useState(); const [ storageLocationChangeDialogVisible, @@ -84,7 +83,6 @@ const Settings = () => { }); setSnackbarMessage('Meme metadata refreshed.'); - setSnackbarVisible(true); }; return ( @@ -162,17 +160,18 @@ const Settings = () => { setSnackbarVisible(false)} + visible={!!snackbarMessage} + // eslint-disable-next-line unicorn/no-useless-undefined + onDismiss={() => setSnackbarMessage(undefined)} style={settingsStyles.snackbar} action={{ label: 'Dismiss', - onPress: () => setSnackbarVisible(false), + // eslint-disable-next-line unicorn/no-useless-undefined + onPress: () => setSnackbarMessage(undefined), }}> {snackbarMessage} diff --git a/src/utilities/clipboard.ts b/src/utilities/clipboard.ts new file mode 100644 index 0000000..99e6d9a --- /dev/null +++ b/src/utilities/clipboard.ts @@ -0,0 +1,11 @@ +import Clipboard from '@react-native-clipboard/clipboard'; + +const clipboardHasContent = () => { + return Promise.all([Clipboard.hasString(), Clipboard.hasURI()]).then( + ([hasString, hasURI]) => { + return hasString || hasURI; + }, + ); +}; + +export { clipboardHasContent }; diff --git a/src/utilities/filesystem.ts b/src/utilities/filesystem.ts index 0b0bf67..e62ee33 100644 --- a/src/utilities/filesystem.ts +++ b/src/utilities/filesystem.ts @@ -2,6 +2,7 @@ import { MEME_TYPE } from '../database'; const allowedImageMimeTypes = [ 'image/bmp', + 'image/jpg', 'image/jpeg', 'image/png', 'image/webp', @@ -14,6 +15,7 @@ const allowedMimeTypes = [...allowedImageMimeTypes, ...allowedGifMimeTypes]; const getMemeType = (mimeType: string): MEME_TYPE | undefined => { switch (mimeType) { case 'image/bmp': + case 'image/jpg': case 'image/jpeg': case 'image/png': case 'image/webp': { diff --git a/src/utilities/index.ts b/src/utilities/index.ts index ea34b91..c02b2c4 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ +export { clipboardHasContent } from './clipboard'; export { getContrastColor, isHexColor,