diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index 595f566c47..a5acad25f6 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -1,26 +1,30 @@ -import ImageResizer from '@bam.tech/react-native-image-resizer' +import {deleteAsync} from 'expo-file-system' +import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' import RNFetchBlob from 'rn-fetch-blob' import { downloadAndResize, DownloadAndResizeOpts, + getResizedDimensions, } from '../../src/lib/media/manip' +const mockResizedImage = { + path: 'file://resized-image.jpg', + size: 100, + width: 100, + height: 100, + mime: 'image/jpeg', +} + describe('downloadAndResize', () => { const errorSpy = jest.spyOn(global.console, 'error') - const mockResizedImage = { - path: jest.fn().mockReturnValue('file://resized-image.jpg'), - size: 100, - width: 50, - height: 50, - mime: 'image/jpeg', - } - beforeEach(() => { - const mockedCreateResizedImage = - ImageResizer.createResizedImage as jest.Mock - mockedCreateResizedImage.mockResolvedValue(mockResizedImage) + const mockedCreateResizedImage = manipulateAsync as jest.Mock + mockedCreateResizedImage.mockResolvedValue({ + uri: 'file://resized-image.jpg', + ...mockResizedImage, + }) }) afterEach(() => { @@ -54,17 +58,17 @@ describe('downloadAndResize', () => { 'GET', 'https://example.com/image.jpg', ) - expect(ImageResizer.createResizedImage).toHaveBeenCalledWith( - 'file://downloaded-image.jpg', - 100, - 100, - 'JPEG', - 100, - undefined, - undefined, - undefined, - {mode: 'cover'}, + + // First time it gets called is to get dimensions + expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {}) + expect(manipulateAsync).toHaveBeenCalledWith( + expect.any(String), + [{resize: {height: opts.height, width: opts.width}}], + {format: SaveFormat.JPEG, compress: 1.0}, ) + expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), { + idempotent: true, + }) }) it('should return undefined for invalid URI', async () => { @@ -82,11 +86,11 @@ describe('downloadAndResize', () => { expect(result).toBeUndefined() }) - it('should return undefined for unsupported file type', async () => { + 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: 200}), + info: jest.fn().mockReturnValue({status: 400}), flush: jest.fn(), }) @@ -100,47 +104,47 @@ 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', - ) - expect(ImageResizer.createResizedImage).toHaveBeenCalledWith( - 'file://downloaded-image', - 100, - 100, - 'JPEG', - 100, - undefined, - undefined, - undefined, - {mode: 'cover'}, - ) + expect(errorSpy).not.toHaveBeenCalled() + 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(), - }) + it('should not downsize whenever dimensions are below the max dimensions', () => { + const initialDimensionsOne = { + width: 1200, + height: 1000, + } + const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) - const opts: DownloadAndResizeOpts = { - uri: 'https://example.com/image', - width: 100, - height: 100, - maxSize: 500000, - mode: 'cover', - timeout: 10000, + const initialDimensionsTwo = { + width: 1000, + height: 1200, } + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) - const result = await downloadAndResize(opts) - expect(errorSpy).not.toHaveBeenCalled() - expect(result).toBeUndefined() + expect(resizedDimensionsOne).toEqual(initialDimensionsOne) + expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo) + }) + + it('should resize dimensions and maintain aspect ratio if they are above the max dimensons', () => { + const initialDimensionsOne = { + width: 3000, + height: 1500, + } + const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) + + const initialDimensionsTwo = { + width: 2000, + height: 4000, + } + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) + + expect(resizedDimensionsOne).toEqual({ + width: 2000, + height: 1000, + }) + expect(resizedDimensionsTwo).toEqual({ + width: 1000, + height: 2000, + }) }) }) diff --git a/jest/jestSetup.js b/jest/jestSetup.js index a68c1dc4bf..50a33589ea 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -42,8 +42,16 @@ jest.mock('rn-fetch-blob', () => ({ fetch: jest.fn(), })) -jest.mock('@bam.tech/react-native-image-resizer', () => ({ - createResizedImage: jest.fn(), +jest.mock('expo-file-system', () => ({ + getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}), + deleteAsync: jest.fn(), +})) + +jest.mock('expo-image-manipulator', () => ({ + manipulateAsync: jest.fn().mockResolvedValue({ + uri: 'file://resized-image', + }), + SaveFormat: jest.requireActual('expo-image-manipulator').SaveFormat, })) jest.mock('@segment/analytics-react-native', () => ({ diff --git a/package.json b/package.json index 5b2369d2a1..4b3486545e 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ }, "dependencies": { "@atproto/api": "^0.13.7", - "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/react": "^1.1.1", diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 3f01e98c5e..e75f13755f 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -6,18 +6,20 @@ import { copyAsync, deleteAsync, EncodingType, + getInfoAsync, makeDirectoryAsync, StorageAccessFramework, writeAsStringAsync, } from 'expo-file-system' +import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' import * as MediaLibrary from 'expo-media-library' import * as Sharing from 'expo-sharing' -import ImageResizer from '@bam.tech/react-native-image-resizer' 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 {isAndroid, isIOS} from '#/platform/detection' import {Dimensions} from './types' export async function compressIfNeeded( @@ -165,29 +167,47 @@ interface DoResizeOpts { } async function doResize(localUri: string, opts: DoResizeOpts): Promise { + // We need to get the dimensions of the image before we resize it. Previously, the library we used allowed us to enter + // a "max size", and it would do the "best possible size" calculation for us. + // Now instead, we have to supply the final dimensions to the manipulation function instead. + // Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize() + // does not work for local files... + const imageRes = await manipulateAsync(localUri, [], {}) + const newDimensions = getResizedDimensions({ + width: imageRes.width, + height: imageRes.height, + }) + for (let i = 0; i < 9; i++) { - const quality = 100 - i * 10 - const resizeRes = await ImageResizer.createResizedImage( + // nearest 10th + const quality = Math.round((1 - 0.1 * i) * 10) / 10 + const resizeRes = await manipulateAsync( localUri, - opts.width, - opts.height, - 'JPEG', - quality, - undefined, - undefined, - undefined, - {mode: opts.mode}, + [{resize: newDimensions}], + { + format: SaveFormat.JPEG, + compress: quality, + }, ) - if (resizeRes.size < opts.maxSize) { + + const fileInfo = await getInfoAsync(resizeRes.uri) + if (!fileInfo.exists) { + throw new Error( + 'The image manipulation library failed to create a new image.', + ) + } + + if (fileInfo.size < opts.maxSize) { + safeDeleteAsync(imageRes.uri) return { - path: normalizePath(resizeRes.path), + path: normalizePath(resizeRes.uri), mime: 'image/jpeg', - size: resizeRes.size, + size: fileInfo.size, width: resizeRes.width, height: resizeRes.height, } } else { - safeDeleteAsync(resizeRes.path) + safeDeleteAsync(resizeRes.uri) } } throw new Error( @@ -311,3 +331,25 @@ async function withTempFile( safeDeleteAsync(tmpDirUri) } } + +export function getResizedDimensions(originalDims: { + width: number + height: number +}) { + if ( + originalDims.width <= POST_IMG_MAX.width && + originalDims.height <= POST_IMG_MAX.height + ) { + return originalDims + } + + const ratio = Math.min( + POST_IMG_MAX.width / originalDims.width, + POST_IMG_MAX.height / originalDims.height, + ) + + return { + width: Math.round(originalDims.width * ratio), + height: Math.round(originalDims.height * ratio), + } +} diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index 1a36b50348..60afadefea 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -2,23 +2,18 @@ import {useEffect, useState} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {logger} from '#/logger' -import {createComposerImage} from '#/state/gallery' -import {useFetchDid} from '#/state/queries/handle' -import {useGetPost} from '#/state/queries/post' -import {useAgent} from '#/state/session' -import * as apilib from 'lib/api/index' -import {POST_IMG_MAX} from 'lib/constants' +import * as apilib from '#/lib/api/index' +import {POST_IMG_MAX} from '#/lib/constants' import { EmbeddingDisabledError, getFeedAsEmbed, getListAsEmbed, getPostAsQuote, getStarterPackAsEmbed, -} from 'lib/link-meta/bsky' -import {getLinkMeta} from 'lib/link-meta/link-meta' -import {resolveShortLink} from 'lib/link-meta/resolve-short-link' -import {downloadAndResize} from 'lib/media/manip' +} from '#/lib/link-meta/bsky' +import {getLinkMeta} from '#/lib/link-meta/link-meta' +import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' +import {downloadAndResize} from '#/lib/media/manip' import { isBskyCustomFeedUrl, isBskyListUrl, @@ -26,8 +21,13 @@ import { isBskyStarterPackUrl, isBskyStartUrl, isShortLink, -} from 'lib/strings/url-helpers' -import {ComposerOpts} from 'state/shell/composer' +} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {createComposerImage} from '#/state/gallery' +import {useFetchDid} from '#/state/queries/handle' +import {useGetPost} from '#/state/queries/post' +import {useAgent} from '#/state/session' +import {ComposerOpts} from '#/state/shell/composer' export function useExternalLinkFetch({ setQuote, diff --git a/yarn.lock b/yarn.lock index 225f109f74..17fe862372 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2983,11 +2983,6 @@ "@babel/helper-validator-identifier" "^7.24.6" to-fast-properties "^2.0.0" -"@bam.tech/react-native-image-resizer@^3.0.4": - version "3.0.5" - resolved "https://registry.yarnpkg.com/@bam.tech/react-native-image-resizer/-/react-native-image-resizer-3.0.5.tgz#6661ba020de156268f73bdc92fbb93ef86f88a13" - integrity sha512-u5QGUQGGVZiVCJ786k9/kd7pPRZ6eYfJCYO18myVCH8FbVI7J8b5GT2Svjj2x808DlWeqfaZOOzxPqo27XYvrQ== - "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"