diff --git a/native/components/full-screen-view-modal.react.js b/native/components/full-screen-view-modal.react.js --- a/native/components/full-screen-view-modal.react.js +++ b/native/components/full-screen-view-modal.react.js @@ -2,13 +2,7 @@ import invariant from 'invariant'; import * as React from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - Platform, -} from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { type PinchGestureEvent, type PanGestureEvent, @@ -735,7 +729,7 @@ } let copyButton; - if (Platform.OS === 'ios' && copyContentCallback) { + if (copyContentCallback) { copyButton = ( <TouchableOpacity onPress={copyContentCallback} diff --git a/native/media/image-modal.react.js b/native/media/image-modal.react.js --- a/native/media/image-modal.react.js +++ b/native/media/image-modal.react.js @@ -1,12 +1,12 @@ // @flow -import Clipboard from '@react-native-clipboard/clipboard'; import * as React from 'react'; +import { Platform } from 'react-native'; import type { MediaInfo, Dimensions } from 'lib/types/media-types.js'; import Multimedia from './multimedia.react.js'; -import { useIntentionalSaveMedia } from './save-media.js'; +import { useIntentionalSaveMedia, copyMediaIOS } from './save-media.js'; import FullScreenViewModal from '../components/full-screen-view-modal.react.js'; import { displayActionResultModal } from '../navigation/action-result-modal.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; @@ -47,12 +47,11 @@ return intentionalSaveMedia(mediaInfo, ids); }, [intentionalSaveMedia, item.messageInfo, mediaInfo]); - const onPressCopy = React.useCallback(() => { - const { uri } = mediaInfo; - Clipboard.setImageFromURL(uri, success => { - displayActionResultModal(success ? 'copied!' : 'failed to copy :('); - }); + const onPressCopyIOS = React.useCallback(async () => { + const { success } = await copyMediaIOS(mediaInfo); + displayActionResultModal(success ? 'copied!' : 'failed to copy :('); }, [mediaInfo]); + const onPressCopy = Platform.OS === 'ios' ? onPressCopyIOS : undefined; const imageDimensions: Dimensions = React.useMemo(() => { const frame = { diff --git a/native/media/save-media.js b/native/media/save-media.js --- a/native/media/save-media.js +++ b/native/media/save-media.js @@ -1,5 +1,6 @@ // @flow +import Clipboard from '@react-native-clipboard/clipboard'; import * as MediaLibrary from 'expo-media-library'; import invariant from 'invariant'; import * as React from 'react'; @@ -311,36 +312,25 @@ ): Promise<$ReadOnlyArray<MediaMissionStep>> { const steps: Array<MediaMissionStep> = []; - let uri = inputURI; - let tempFile; - if (uri.startsWith('http') || isBlobServiceURI(uri)) { - const { result: tempSaveResult, steps: tempSaveSteps } = - await saveRemoteMediaToDisk(uri, encryptionKey, temporaryDirectoryPath); - steps.push(...tempSaveSteps); - if (!tempSaveResult.success) { - sendResult(tempSaveResult); - return steps; - } - tempFile = tempSaveResult.path; - uri = `file://${tempFile}`; - } else if (!uri.startsWith('file://')) { - const mediaNativeID = getMediaLibraryIdentifier(uri); - if (mediaNativeID) { - const { result: fetchAssetInfoResult, steps: fetchAssetInfoSteps } = - await fetchAssetInfo(mediaNativeID); - steps.push(...fetchAssetInfoSteps); - const { localURI } = fetchAssetInfoResult; - if (localURI) { - uri = localURI; - } - } - } + const saveMediaToDiskIOSResult = await saveMediaToDiskIOS( + inputURI, + encryptionKey, + ); - if (!uri.startsWith('file://')) { - sendResult({ success: false, reason: 'resolve_failed', uri }); + steps.push(...saveMediaToDiskIOSResult.steps); + const { tempFilePath } = saveMediaToDiskIOSResult; + + if (!saveMediaToDiskIOSResult.success) { + if (tempFilePath) { + const disposeStep = await disposeTempFile(tempFilePath); + steps.push(disposeStep); + } + sendResult(saveMediaToDiskIOSResult.result); return steps; } + const { uri } = saveMediaToDiskIOSResult; + let success = false, exceptionMessage; const start = Date.now(); @@ -364,16 +354,82 @@ sendResult({ success: false, reason: 'save_to_library_failed', uri }); } - if (tempFile) { - const disposeStep = await disposeTempFile(tempFile); + if (tempFilePath) { + const disposeStep = await disposeTempFile(tempFilePath); steps.push(disposeStep); } return steps; } +type SaveMediaToDiskIOSResult = + | { + +success: true, + +uri: string, + +tempFilePath: ?string, + +steps: $ReadOnlyArray<MediaMissionStep>, + } + | { + +success: false, + +result: MediaMissionResult, + +tempFilePath?: ?string, + +steps: $ReadOnlyArray<MediaMissionStep>, + }; +async function saveMediaToDiskIOS( + inputURI: string, + encryptionKey?: ?string, +): Promise<SaveMediaToDiskIOSResult> { + const steps: Array<MediaMissionStep> = []; + + let uri = inputURI; + let tempFilePath; + if (uri.startsWith('http') || isBlobServiceURI(uri)) { + const { result: tempSaveResult, steps: tempSaveSteps } = + await saveRemoteMediaToDisk(uri, encryptionKey, temporaryDirectoryPath); + steps.push(...tempSaveSteps); + if (!tempSaveResult.success) { + return { + success: false, + result: tempSaveResult, + steps, + }; + } + tempFilePath = tempSaveResult.path; + uri = `file://${tempFilePath}`; + } else if (!uri.startsWith('file://')) { + const mediaNativeID = getMediaLibraryIdentifier(uri); + if (mediaNativeID) { + const { result: fetchAssetInfoResult, steps: fetchAssetInfoSteps } = + await fetchAssetInfo(mediaNativeID); + steps.push(...fetchAssetInfoSteps); + const { localURI } = fetchAssetInfoResult; + if (localURI) { + uri = localURI; + } + } + } + + if (!uri.startsWith('file://')) { + return { + success: false, + result: { success: false, reason: 'resolve_failed', uri }, + tempFilePath, + steps, + }; + } + + return { + success: true, + uri, + tempFilePath, + steps, + }; +} + type IntermediateSaveResult = { - result: { success: true, path: string, mime: string } | MediaMissionFailure, - steps: $ReadOnlyArray<MediaMissionStep>, + +result: + | { +success: true, +path: string, +mime: string } + | MediaMissionFailure, + +steps: $ReadOnlyArray<MediaMissionStep>, }; async function saveRemoteMediaToDisk( @@ -550,4 +606,32 @@ }; } -export { useIntentionalSaveMedia, saveMedia }; +async function copyMediaIOS( + mediaInfo: MediaInfo, +): Promise<{ +success: boolean }> { + const { uri: mediaURI, blobURI, holder, encryptionKey } = mediaInfo; + const inputURI = mediaURI ?? blobURI ?? holder; + invariant(inputURI, 'mediaInfo should have a uri or a blobURI'); + + const saveMediaToDiskIOSResult = await saveMediaToDiskIOS( + inputURI, + encryptionKey, + ); + + if (!saveMediaToDiskIOSResult.success) { + const { tempFilePath } = saveMediaToDiskIOSResult; + if (tempFilePath) { + await disposeTempFile(tempFilePath); + } + return { success: false }; + } + + const { uri } = saveMediaToDiskIOSResult; + return new Promise<{ +success: boolean }>(resolve => { + Clipboard.setImageFromURL(uri, success => { + resolve({ success }); + }); + }); +} + +export { useIntentionalSaveMedia, saveMedia, copyMediaIOS }; diff --git a/patches/@react-native-clipboard+clipboard+1.11.1.patch b/patches/@react-native-clipboard+clipboard+1.11.1.patch --- a/patches/@react-native-clipboard+clipboard+1.11.1.patch +++ b/patches/@react-native-clipboard+clipboard+1.11.1.patch @@ -97,7 +97,7 @@ * Set content of string array type. You can use following code to set clipboard content * ```javascript diff --git a/node_modules/@react-native-clipboard/clipboard/ios/RNCClipboard.m b/node_modules/@react-native-clipboard/clipboard/ios/RNCClipboard.m -index 04143f4..97a4359 100644 +index 04143f4..0edd268 100644 --- a/node_modules/@react-native-clipboard/clipboard/ios/RNCClipboard.m +++ b/node_modules/@react-native-clipboard/clipboard/ios/RNCClipboard.m @@ -4,6 +4,8 @@ @@ -109,7 +109,7 @@ @implementation RNCClipboard { -@@ -146,6 +148,100 @@ - (void) listener:(NSNotification *) notification +@@ -146,6 +148,123 @@ - (void) listener:(NSNotification *) notification resolve([NSNumber numberWithBool: imagePresent]); } @@ -134,20 +134,43 @@ +} + + -+RCT_EXPORT_METHOD(setImageFromURL: (NSString *)url ++RCT_EXPORT_METHOD(setImageFromURL: (NSString *)urlString + success:(RCTResponseSenderBlock)success) +{ -+ [SDWebImageManager.sharedManager loadImageWithURL:[NSURL URLWithString: url] options:0 progress:NULL completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) { -+ -+ if (error || !finished) { -+ success(@[@NO]); ++ NSURL *url = [NSURL URLWithString:urlString]; ++ ++ if ([url.scheme isEqualToString:@"https"] || [url.scheme isEqualToString:@"http"]) { ++ [SDWebImageManager.sharedManager loadImageWithURL:url options:0 progress:NULL completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) { ++ ++ if (error || !finished) { ++ success(@[@NO]); ++ return; ++ } ++ ++ UIPasteboard *clipboard = [UIPasteboard generalPasteboard]; ++ clipboard.image = image; ++ success(@[@YES]); ++ }]; ++ } else if ([url.scheme isEqualToString:@"file"]) { ++ NSString *filePath = [url path]; ++ if (![[NSFileManager defaultManager] fileExistsAtPath:filePath]) { ++ NSLog(@"File does not exist at path: %@", filePath); ++ return; ++ } ++ ++ NSData *imageData = [NSData dataWithContentsOfFile:filePath]; ++ UIImage *image = [UIImage imageWithData:imageData]; ++ if (!image) { ++ NSLog(@"Failed to create UIImage from local file at path: %@", filePath); + return; + } -+ ++ + UIPasteboard *clipboard = [UIPasteboard generalPasteboard]; + clipboard.image = image; + success(@[@YES]); -+ }]; ++ } else { ++ NSLog(@"Unsupported URL scheme: %@", url.scheme); ++ } +} + +RCT_EXPORT_METHOD(getPNGImageData : (RCTPromiseResolveBlock)resolve reject : (__unused RCTPromiseRejectBlock)reject)