From 00a8e12cee5919a0d68dbacd025cf36793e4f5d5 Mon Sep 17 00:00:00 2001 From: hailey Date: Tue, 29 Apr 2025 21:47:30 -0700 Subject: [PATCH] [new arch] remove rn-fetch-blob dependency (#8294) * remove fetchblob dependency * give error a name * fix timeout * give qr code dialog a handle * fix error toast text * more bumps that let the thing build on ios * bumps that let it build for ios * [new arch] experiment - use animatedview/animatedref to measure rect for lightbox (#8298) * more upgrades * use animatedview/animatedref for measurements in lightbox * rm usehandleref * downgrade dynamic app icon * more bumps that let the thing build on ios * bumps that let it build for ios * bump expo --------- Co-authored-by: Samuel Newman --------- Co-authored-by: Samuel Newman --- __tests__/lib/images.test.ts | 51 +++-------- jest/jestSetup.js | 9 +- package.json | 1 - src/components/StarterPack/QrCodeDialog.tsx | 5 +- src/components/StarterPack/ShareDialog.tsx | 8 +- src/lib/media/manip.ts | 97 +++++++++------------ yarn.lock | 27 +----- 7 files changed, 65 insertions(+), 133 deletions(-) diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index a5acad25f6..c7a645d39c 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -1,10 +1,9 @@ -import {deleteAsync} from 'expo-file-system' +import {createDownloadResumable, deleteAsync} from 'expo-file-system' import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' -import RNFetchBlob from 'rn-fetch-blob' import { downloadAndResize, - DownloadAndResizeOpts, + type DownloadAndResizeOpts, getResizedDimensions, } from '../../src/lib/media/manip' @@ -32,11 +31,12 @@ describe('downloadAndResize', () => { }) it('should return resized image for valid URI and options', async () => { - const mockedFetch = RNFetchBlob.fetch as jest.Mock - mockedFetch.mockResolvedValueOnce({ - path: jest.fn().mockReturnValue('file://downloaded-image.jpg'), - info: jest.fn().mockReturnValue({status: 200}), - flush: jest.fn(), + const mockedFetch = createDownloadResumable as jest.Mock + mockedFetch.mockReturnValue({ + cancelAsync: jest.fn(), + downloadAsync: jest + .fn() + .mockResolvedValue({uri: 'file://resized-image.jpg'}), }) const opts: DownloadAndResizeOpts = { @@ -50,13 +50,12 @@ describe('downloadAndResize', () => { const result = await downloadAndResize(opts) expect(result).toEqual(mockResizedImage) - expect(RNFetchBlob.config).toHaveBeenCalledWith({ - fileCache: true, - appendExt: 'jpeg', - }) - expect(RNFetchBlob.fetch).toHaveBeenCalledWith( - 'GET', - 'https://example.com/image.jpg', + expect(createDownloadResumable).toHaveBeenCalledWith( + opts.uri, + expect.anything(), + { + cache: true, + }, ) // First time it gets called is to get dimensions @@ -86,28 +85,6 @@ describe('downloadAndResize', () => { expect(result).toBeUndefined() }) - it('should return undefined for non-200 response', async () => { - const mockedFetch = RNFetchBlob.fetch as jest.Mock - mockedFetch.mockResolvedValueOnce({ - path: jest.fn().mockReturnValue('file://downloaded-image'), - info: jest.fn().mockReturnValue({status: 400}), - flush: jest.fn(), - }) - - const opts: DownloadAndResizeOpts = { - uri: 'https://example.com/image', - width: 100, - height: 100, - maxSize: 500000, - mode: 'cover', - timeout: 10000, - } - - const result = await downloadAndResize(opts) - expect(errorSpy).not.toHaveBeenCalled() - expect(result).toBeUndefined() - }) - it('should not downsize whenever dimensions are below the max dimensions', () => { const initialDimensionsOne = { width: 1200, diff --git a/jest/jestSetup.js b/jest/jestSetup.js index d303225f6c..700d20afc0 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -33,15 +33,10 @@ jest.mock('react-native-safe-area-context', () => { } }) -jest.mock('rn-fetch-blob', () => ({ - config: jest.fn().mockReturnThis(), - cancel: jest.fn(), - fetch: jest.fn(), -})) - jest.mock('expo-file-system', () => ({ getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}), deleteAsync: jest.fn(), + createDownloadResumable: jest.fn(), })) jest.mock('expo-image-manipulator', () => ({ @@ -101,7 +96,7 @@ jest.mock('expo-modules-core', () => ({ } } }), - requireNativeViewManager: jest.fn().mockImplementation(moduleName => { + requireNativeViewManager: jest.fn().mockImplementation(_ => { return () => null }), })) diff --git a/package.json b/package.json index 253622189a..c4e8d44dc0 100644 --- a/package.json +++ b/package.json @@ -211,7 +211,6 @@ "react-remove-scroll-bar": "^2.3.8", "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/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx index 43d8b72dad..19fd6257c8 100644 --- a/src/components/StarterPack/QrCodeDialog.tsx +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -4,7 +4,7 @@ import type ViewShot from 'react-native-view-shot' import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' import {createAssetAsync} from 'expo-media-library' import * as Sharing from 'expo-sharing' -import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' +import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -15,7 +15,7 @@ import * as Toast from '#/view/com/util/Toast' import {atoms as a} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {DialogControlProps} from '#/components/Dialog' +import {type DialogControlProps} from '#/components/Dialog' import {Loader} from '#/components/Loader' import {QrCode} from '#/components/StarterPack/QrCode' import * as bsky from '#/types/bsky' @@ -155,6 +155,7 @@ export function QrCodeDialog({ return ( + diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx index 354d7bc4ee..ae7e6c418a 100644 --- a/src/components/StarterPack/ShareDialog.tsx +++ b/src/components/StarterPack/ShareDialog.tsx @@ -1,7 +1,7 @@ import {View} from 'react-native' import {Image} from 'expo-image' import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' -import {AppBskyGraphDefs} from '@atproto/api' +import {type AppBskyGraphDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -15,7 +15,7 @@ import {isNative, isWeb} from '#/platform/detection' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' -import {DialogControlProps} from '#/components/Dialog' +import {type DialogControlProps} from '#/components/Dialog' import * as Dialog from '#/components/Dialog' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' @@ -76,8 +76,8 @@ function ShareDialogInner({ Toast.show(_(msg`Image saved to your camera roll!`)) control.close() } catch (e: unknown) { - Toast.show(_(msg`An error occurred while saving the QR code!`), 'xmark') - logger.error('Failed to save QR code', {error: e}) + Toast.show(_(msg`An error occurred while saving the image!`), 'xmark') + logger.error('Failed to save starter pack image', {error: e}) return } } diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index f6ef8347de..b56964596f 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -1,9 +1,10 @@ -import {Image as RNImage, Share as RNShare} from 'react-native' -import {Image} from 'react-native-image-crop-picker' +import {Image as RNImage} from 'react-native' +import {type Image} from 'react-native-image-crop-picker' import uuid from 'react-native-uuid' import { cacheDirectory, copyAsync, + createDownloadResumable, deleteAsync, EncodingType, getInfoAsync, @@ -15,12 +16,11 @@ 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' import {isAndroid, isIOS} from '#/platform/detection' -import {Dimensions} from './types' +import {type Dimensions} from './types' export async function compressIfNeeded( img: Image, @@ -69,28 +69,13 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) { return } - let downloadRes + const path = createPath(appendExt) + try { - const downloadResPromise = RNFetchBlob.config({ - fileCache: true, - appendExt, - }).fetch('GET', opts.uri) - const to1 = setTimeout(() => downloadResPromise.cancel(), opts.timeout) - downloadRes = await downloadResPromise - clearTimeout(to1) - - const status = downloadRes.info().status - if (status !== 200) { - return - } - - const localUri = normalizePath(downloadRes.path(), true) - return await doResize(localUri, opts) + await downloadImage(opts.uri, path, opts.timeout) + return await doResize(path, opts) } finally { - // TODO Whenever we remove `rn-fetch-blob`, we will need to replace this `flush()` with a `deleteAsync()` -hailey - if (downloadRes) { - downloadRes.flush() - } + safeDeleteAsync(path) } } @@ -99,32 +84,16 @@ 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({ - fileCache: true, - }).fetch('GET', uri) - // NOTE - // assuming PNG // we're currently relying on the fact our CDN only serves pngs // -prf - - let imagePath = downloadResponse.path() - imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true) - - // NOTE - // for some reason expo-sharing refuses to work on iOS - // ...and visa versa - // -prf - if (isIOS) { - await RNShare.share({url: imagePath}) - } else { - await Sharing.shareAsync(imagePath, { - mimeType: 'image/png', - UTI: 'image/png', - }) - } - - safeDeleteAsync(imagePath) + const imageUri = await downloadImage(uri, createPath('png'), 5e3) + const imagePath = await moveToPermanentPath(imageUri, '.png') + safeDeleteAsync(imageUri) + await Sharing.shareAsync(imagePath, { + mimeType: 'image/png', + UTI: 'image/png', + }) } export async function saveImageToMediaLibrary({uri}: {uri: string}) { @@ -133,15 +102,10 @@ 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({ - fileCache: true, - }).fetch('GET', uri) - let imagePath = downloadResponse.path() - imagePath = normalizePath(await moveToPermanentPath(imagePath, '.png'), true) - - // save - await MediaLibrary.createAssetAsync(imagePath) - safeDeleteAsync(imagePath) + const imageUri = await downloadImage(uri, createPath('png'), 5e3) + const imagePath = await moveToPermanentPath(imageUri, '.png') + safeDeleteAsync(imageUri) + MediaLibrary.createAssetAsync(imagePath) } export function getImageDim(path: string): Promise { @@ -366,3 +330,24 @@ export function getResizedDimensions(originalDims: { height: Math.round(originalDims.height * ratio), } } + +function createPath(ext: string) { + // cacheDirectory will never be null on native, so the null check here is not necessary except for typescript. + // we use a web-only function for downloadAndResize on web + return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}` +} + +async function downloadImage(uri: string, path: string, timeout: number) { + const dlResumable = createDownloadResumable(uri, path, {cache: true}) + + const to1 = setTimeout(() => dlResumable.cancelAsync(), timeout) + + const dlRes = await dlResumable.downloadAsync() + clearTimeout(to1) + + if (!dlRes?.uri) { + throw new Error('Failed to download image - dlRes is undefined') + } + + return normalizePath(dlRes.uri) +} diff --git a/yarn.lock b/yarn.lock index 42e59f71e9..cc2c816301 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8760,11 +8760,6 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base-64@0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" - integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA== - base64-arraybuffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" @@ -12085,18 +12080,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.3.10: version "10.3.12" resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.12.tgz#3a65c363c2e9998d220338e88a5f6ac97302960b" @@ -14857,7 +14840,7 @@ minimalistic-crypto-utils@^1.0.1: resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== -minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: +minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -17552,14 +17535,6 @@ rimraf@^3.0.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"