diff --git a/__mocks__/rn-fetch-blob.js b/__mocks__/react-native-blob-util.js similarity index 71% rename from __mocks__/rn-fetch-blob.js rename to __mocks__/react-native-blob-util.js index dedfbdf896..f3f387744f 100644 --- a/__mocks__/rn-fetch-blob.js +++ b/__mocks__/react-native-blob-util.js @@ -1,4 +1,4 @@ -jest.mock('rn-fetch-blob', () => { +jest.mock('react-native-blob-util', () => { return { __esModule: true, default: { diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index a5acad25f6..46a2e2e759 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -1,6 +1,6 @@ +import ReactNativeBlobUtil from 'react-native-blob-util' import {deleteAsync} from 'expo-file-system' import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' -import RNFetchBlob from 'rn-fetch-blob' import { downloadAndResize, @@ -32,7 +32,7 @@ describe('downloadAndResize', () => { }) it('should return resized image for valid URI and options', async () => { - const mockedFetch = RNFetchBlob.fetch as jest.Mock + const mockedFetch = ReactNativeBlobUtil.fetch as jest.Mock mockedFetch.mockResolvedValueOnce({ path: jest.fn().mockReturnValue('file://downloaded-image.jpg'), info: jest.fn().mockReturnValue({status: 200}), @@ -50,11 +50,11 @@ describe('downloadAndResize', () => { const result = await downloadAndResize(opts) expect(result).toEqual(mockResizedImage) - expect(RNFetchBlob.config).toHaveBeenCalledWith({ + expect(ReactNativeBlobUtil.config).toHaveBeenCalledWith({ fileCache: true, appendExt: 'jpeg', }) - expect(RNFetchBlob.fetch).toHaveBeenCalledWith( + expect(ReactNativeBlobUtil.fetch).toHaveBeenCalledWith( 'GET', 'https://example.com/image.jpg', ) @@ -87,7 +87,7 @@ describe('downloadAndResize', () => { }) it('should return undefined for non-200 response', async () => { - const mockedFetch = RNFetchBlob.fetch as jest.Mock + const mockedFetch = ReactNativeBlobUtil.fetch as jest.Mock mockedFetch.mockResolvedValueOnce({ path: jest.fn().mockReturnValue('file://downloaded-image'), info: jest.fn().mockReturnValue({status: 400}), diff --git a/jest/jestSetup.js b/jest/jestSetup.js index c3160df3bc..56611fe512 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -33,7 +33,7 @@ jest.mock('react-native-safe-area-context', () => { } }) -jest.mock('rn-fetch-blob', () => ({ +jest.mock('react-native-blob-util', () => ({ config: jest.fn().mockReturnThis(), cancel: jest.fn(), fetch: jest.fn(), diff --git a/package.json b/package.json index 5e1394c2cf..dc3c42ab46 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "react-image-crop": "^11.0.7", "react-keyed-flatten-children": "^3.0.0", "react-native": "0.76.3", + "react-native-blob-util": "^0.21.2", "react-native-compressor": "1.10.3", "react-native-date-picker": "^5.0.7", "react-native-drawer-layout": "^4.0.4", @@ -196,7 +197,6 @@ "react-remove-scroll-bar": "^2.3.6", "react-responsive": "^9.0.2", "react-textarea-autosize": "^8.5.3", - "rn-fetch-blob": "^0.12.0", "statsig-react-native-expo": "^4.6.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index e75f13755f..8b99da097e 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -1,4 +1,5 @@ import {Image as RNImage, Share as RNShare} from 'react-native' +import ReactNativeBlobUtil from 'react-native-blob-util' import {Image} from 'react-native-image-crop-picker' import uuid from 'react-native-uuid' import { @@ -15,7 +16,6 @@ import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' import * as MediaLibrary from 'expo-media-library' import * as Sharing from 'expo-sharing' import {Buffer} from 'buffer' -import RNFetchBlob from 'rn-fetch-blob' import {POST_IMG_MAX} from '#/lib/constants' import {logger} from '#/logger' @@ -71,7 +71,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { let downloadRes try { - const downloadResPromise = RNFetchBlob.config({ + const downloadResPromise = ReactNativeBlobUtil.config({ fileCache: true, appendExt, }).fetch('GET', opts.uri) @@ -87,7 +87,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { const localUri = normalizePath(downloadRes.path(), true) return await doResize(localUri, opts) } finally { - // TODO Whenever we remove `rn-fetch-blob`, we will need to replace this `flush()` with a `deleteAsync()` -hailey + // TODO Whenever we remove `react-native-blob-util`, we will need to replace this `flush()` with a `deleteAsync()` -hailey if (downloadRes) { downloadRes.flush() } @@ -99,7 +99,7 @@ export async function shareImageModal({uri}: {uri: string}) { // TODO might need to give an error to the user in this case -prf return } - const downloadResponse = await RNFetchBlob.config({ + const downloadResponse = await ReactNativeBlobUtil.config({ fileCache: true, }).fetch('GET', uri) @@ -133,15 +133,41 @@ export async function saveImageToMediaLibrary({uri}: {uri: string}) { // assuming PNG // we're currently relying on the fact our CDN only serves pngs // -prf - const downloadResponse = await RNFetchBlob.config({ + const downloadResponse = await ReactNativeBlobUtil.config({ fileCache: true, }).fetch('GET', uri) - let imagePath = downloadResponse.path() - imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true) + let tempImagePath = downloadResponse.path() + tempImagePath = normalizePath( + await moveToPermanentPath(tempImagePath, '.png'), + true, + ) // save - await MediaLibrary.createAssetAsync(imagePath) - safeDeleteAsync(imagePath) + if (isAndroid) { + // We want to save to Pictures/Bluesky for Android, which matches other typical app behaviour + // (see https://github.com/bluesky-social/social-app/issues/1360). + + // A workaround for https://github.com/expo/expo/issues/24591 making + // using MediaLibrary + albums really painful to use (it requires asking the user each time + // to delete the image saved in DCIM). + let finalImagePath = + await ReactNativeBlobUtil.MediaCollection.createMediafile( + { + name: tempImagePath.split('/').pop(), + parentFolder: 'Bluesky', + mimeType: 'image/png', + }, + 'Image', + ) + + await ReactNativeBlobUtil.MediaCollection.writeToMediafile( + finalImagePath, + tempImagePath, + ) + } else { + await MediaLibrary.createAssetAsync(tempImagePath) + } + safeDeleteAsync(tempImagePath) } export function getImageDim(path: string): Promise { diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 628bd2b9af..ab1f935d6f 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -20,11 +20,12 @@ export function Lightbox() { const [permissionResponse, requestPermission] = MediaLibrary.usePermissions({ granularPermissions: ['photo'], }) + const saveImageToAlbumWithToasts = React.useCallback( async (uri: string) => { if (!permissionResponse || permissionResponse.granted === false) { Toast.show( - _(msg`Permission to access camera roll is required.`), + _(msg`Permission to access media library is required.`), 'info', ) if (permissionResponse?.canAskAgain) { @@ -32,7 +33,7 @@ export function Lightbox() { } else { Toast.show( _( - msg`Permission to access camera roll was denied. Please enable it in your system settings.`, + msg`Permission to access media library was denied. Please enable it in your system settings.`, ), 'xmark', ) @@ -41,7 +42,7 @@ export function Lightbox() { } try { await saveImageToMediaLibrary({uri}) - Toast.show(_(msg`Saved to your camera roll`)) + Toast.show(_(msg`Image saved`)) } catch (e: any) { Toast.show(_(msg`Failed to save image: ${String(e)}`), 'xmark') } diff --git a/yarn.lock b/yarn.lock index df8572a289..fd97eb326a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11304,18 +11304,6 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@7.0.6: - version "7.0.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.0.6.tgz#211bafaf49e525b8cd93260d14ab136152b3f57a" - integrity sha512-f8c0rE8JiCxpa52kWPAOa3ZaYEnzofDzCQLCn3Vdk0Z5OVLq3BsRFJI4S4ykpeVW6QMGBUkMeUpoEgWnMTnw5Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.2" - once "^1.3.0" - path-is-absolute "^1.0.0" - glob@^10.2.2: version "10.4.1" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.1.tgz#0cfb01ab6a6b438177bfe6a58e2576f6efe909c2" @@ -15937,6 +15925,14 @@ react-keyed-flatten-children@^3.0.0: dependencies: react-is "^18.2.0" +react-native-blob-util@^0.21.2: + version "0.21.2" + resolved "https://registry.yarnpkg.com/react-native-blob-util/-/react-native-blob-util-0.21.2.tgz#26dc7907b2ef68ae92882bb5e512b3b41c641acb" + integrity sha512-4DsF+zzBEJmLww12PsUjwqjSaUrz7gdL5MeduSRn9fv5M8GLRIk5WHcgc7n+fzorGhbbL9QtB/QLTL6dMKjYUw== + dependencies: + base-64 "0.1.0" + glob "^10.3.10" + react-native-compressor@1.10.3: version "1.10.3" resolved "https://registry.yarnpkg.com/react-native-compressor/-/react-native-compressor-1.10.3.tgz#4e44fa8395de17fd6dc63c074e5a8c2ef06b80a1" @@ -16702,14 +16698,6 @@ rimraf@~2.6.2: dependencies: glob "^7.1.3" -rn-fetch-blob@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/rn-fetch-blob/-/rn-fetch-blob-0.12.0.tgz#ec610d2f9b3f1065556b58ab9c106eeb256f3cba" - integrity sha512-+QnR7AsJ14zqpVVUbzbtAjq0iI8c9tCg49tIoKO2ezjzRunN7YL6zFSFSWZm6d+mE/l9r+OeDM3jmb2tBb2WbA== - dependencies: - base-64 "0.1.0" - glob "7.0.6" - roarr@^7.0.4: version "7.15.1" resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.15.1.tgz#e4d93105c37b5ea7dd1200d96a3500f757ddc39f"