my fork of the bluesky client

Remove image resizer (#5464)

authored by hailey.at and committed by

GitHub ea43d20c d2fae81b

+146 -98
+65 -61
__tests__/lib/images.test.ts
··· 1 - import ImageResizer from '@bam.tech/react-native-image-resizer' 1 + import {deleteAsync} from 'expo-file-system' 2 + import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' 2 3 import RNFetchBlob from 'rn-fetch-blob' 3 4 4 5 import { 5 6 downloadAndResize, 6 7 DownloadAndResizeOpts, 8 + getResizedDimensions, 7 9 } from '../../src/lib/media/manip' 8 10 11 + const mockResizedImage = { 12 + path: 'file://resized-image.jpg', 13 + size: 100, 14 + width: 100, 15 + height: 100, 16 + mime: 'image/jpeg', 17 + } 18 + 9 19 describe('downloadAndResize', () => { 10 20 const errorSpy = jest.spyOn(global.console, 'error') 11 21 12 - const mockResizedImage = { 13 - path: jest.fn().mockReturnValue('file://resized-image.jpg'), 14 - size: 100, 15 - width: 50, 16 - height: 50, 17 - mime: 'image/jpeg', 18 - } 19 - 20 22 beforeEach(() => { 21 - const mockedCreateResizedImage = 22 - ImageResizer.createResizedImage as jest.Mock 23 - mockedCreateResizedImage.mockResolvedValue(mockResizedImage) 23 + const mockedCreateResizedImage = manipulateAsync as jest.Mock 24 + mockedCreateResizedImage.mockResolvedValue({ 25 + uri: 'file://resized-image.jpg', 26 + ...mockResizedImage, 27 + }) 24 28 }) 25 29 26 30 afterEach(() => { ··· 54 58 'GET', 55 59 'https://example.com/image.jpg', 56 60 ) 57 - expect(ImageResizer.createResizedImage).toHaveBeenCalledWith( 58 - 'file://downloaded-image.jpg', 59 - 100, 60 - 100, 61 - 'JPEG', 62 - 100, 63 - undefined, 64 - undefined, 65 - undefined, 66 - {mode: 'cover'}, 61 + 62 + // First time it gets called is to get dimensions 63 + expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {}) 64 + expect(manipulateAsync).toHaveBeenCalledWith( 65 + expect.any(String), 66 + [{resize: {height: opts.height, width: opts.width}}], 67 + {format: SaveFormat.JPEG, compress: 1.0}, 67 68 ) 69 + expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), { 70 + idempotent: true, 71 + }) 68 72 }) 69 73 70 74 it('should return undefined for invalid URI', async () => { ··· 82 86 expect(result).toBeUndefined() 83 87 }) 84 88 85 - it('should return undefined for unsupported file type', async () => { 89 + it('should return undefined for non-200 response', async () => { 86 90 const mockedFetch = RNFetchBlob.fetch as jest.Mock 87 91 mockedFetch.mockResolvedValueOnce({ 88 92 path: jest.fn().mockReturnValue('file://downloaded-image'), 89 - info: jest.fn().mockReturnValue({status: 200}), 93 + info: jest.fn().mockReturnValue({status: 400}), 90 94 flush: jest.fn(), 91 95 }) 92 96 ··· 100 104 } 101 105 102 106 const result = await downloadAndResize(opts) 103 - expect(result).toEqual(mockResizedImage) 104 - expect(RNFetchBlob.config).toHaveBeenCalledWith({ 105 - fileCache: true, 106 - appendExt: 'jpeg', 107 - }) 108 - expect(RNFetchBlob.fetch).toHaveBeenCalledWith( 109 - 'GET', 110 - 'https://example.com/image', 111 - ) 112 - expect(ImageResizer.createResizedImage).toHaveBeenCalledWith( 113 - 'file://downloaded-image', 114 - 100, 115 - 100, 116 - 'JPEG', 117 - 100, 118 - undefined, 119 - undefined, 120 - undefined, 121 - {mode: 'cover'}, 122 - ) 107 + expect(errorSpy).not.toHaveBeenCalled() 108 + expect(result).toBeUndefined() 123 109 }) 124 110 125 - it('should return undefined for non-200 response', async () => { 126 - const mockedFetch = RNFetchBlob.fetch as jest.Mock 127 - mockedFetch.mockResolvedValueOnce({ 128 - path: jest.fn().mockReturnValue('file://downloaded-image'), 129 - info: jest.fn().mockReturnValue({status: 400}), 130 - flush: jest.fn(), 131 - }) 111 + it('should not downsize whenever dimensions are below the max dimensions', () => { 112 + const initialDimensionsOne = { 113 + width: 1200, 114 + height: 1000, 115 + } 116 + const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) 117 + 118 + const initialDimensionsTwo = { 119 + width: 1000, 120 + height: 1200, 121 + } 122 + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) 123 + 124 + expect(resizedDimensionsOne).toEqual(initialDimensionsOne) 125 + expect(resizedDimensionsTwo).toEqual(initialDimensionsTwo) 126 + }) 127 + 128 + it('should resize dimensions and maintain aspect ratio if they are above the max dimensons', () => { 129 + const initialDimensionsOne = { 130 + width: 3000, 131 + height: 1500, 132 + } 133 + const resizedDimensionsOne = getResizedDimensions(initialDimensionsOne) 132 134 133 - const opts: DownloadAndResizeOpts = { 134 - uri: 'https://example.com/image', 135 - width: 100, 136 - height: 100, 137 - maxSize: 500000, 138 - mode: 'cover', 139 - timeout: 10000, 135 + const initialDimensionsTwo = { 136 + width: 2000, 137 + height: 4000, 140 138 } 139 + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) 141 140 142 - const result = await downloadAndResize(opts) 143 - expect(errorSpy).not.toHaveBeenCalled() 144 - expect(result).toBeUndefined() 141 + expect(resizedDimensionsOne).toEqual({ 142 + width: 2000, 143 + height: 1000, 144 + }) 145 + expect(resizedDimensionsTwo).toEqual({ 146 + width: 1000, 147 + height: 2000, 148 + }) 145 149 }) 146 150 })
+10 -2
jest/jestSetup.js
··· 42 42 fetch: jest.fn(), 43 43 })) 44 44 45 - jest.mock('@bam.tech/react-native-image-resizer', () => ({ 46 - createResizedImage: jest.fn(), 45 + jest.mock('expo-file-system', () => ({ 46 + getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}), 47 + deleteAsync: jest.fn(), 48 + })) 49 + 50 + jest.mock('expo-image-manipulator', () => ({ 51 + manipulateAsync: jest.fn().mockResolvedValue({ 52 + uri: 'file://resized-image', 53 + }), 54 + SaveFormat: jest.requireActual('expo-image-manipulator').SaveFormat, 47 55 })) 48 56 49 57 jest.mock('@segment/analytics-react-native', () => ({
-1
package.json
··· 54 54 }, 55 55 "dependencies": { 56 56 "@atproto/api": "^0.13.7", 57 - "@bam.tech/react-native-image-resizer": "^3.0.4", 58 57 "@braintree/sanitize-url": "^6.0.2", 59 58 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", 60 59 "@emoji-mart/react": "^1.1.1",
+58 -16
src/lib/media/manip.ts
··· 6 6 copyAsync, 7 7 deleteAsync, 8 8 EncodingType, 9 + getInfoAsync, 9 10 makeDirectoryAsync, 10 11 StorageAccessFramework, 11 12 writeAsStringAsync, 12 13 } from 'expo-file-system' 14 + import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' 13 15 import * as MediaLibrary from 'expo-media-library' 14 16 import * as Sharing from 'expo-sharing' 15 - import ImageResizer from '@bam.tech/react-native-image-resizer' 16 17 import {Buffer} from 'buffer' 17 18 import RNFetchBlob from 'rn-fetch-blob' 18 19 20 + import {POST_IMG_MAX} from '#/lib/constants' 19 21 import {logger} from '#/logger' 20 - import {isAndroid, isIOS} from 'platform/detection' 22 + import {isAndroid, isIOS} from '#/platform/detection' 21 23 import {Dimensions} from './types' 22 24 23 25 export async function compressIfNeeded( ··· 165 167 } 166 168 167 169 async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> { 170 + // We need to get the dimensions of the image before we resize it. Previously, the library we used allowed us to enter 171 + // a "max size", and it would do the "best possible size" calculation for us. 172 + // Now instead, we have to supply the final dimensions to the manipulation function instead. 173 + // Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize() 174 + // does not work for local files... 175 + const imageRes = await manipulateAsync(localUri, [], {}) 176 + const newDimensions = getResizedDimensions({ 177 + width: imageRes.width, 178 + height: imageRes.height, 179 + }) 180 + 168 181 for (let i = 0; i < 9; i++) { 169 - const quality = 100 - i * 10 170 - const resizeRes = await ImageResizer.createResizedImage( 182 + // nearest 10th 183 + const quality = Math.round((1 - 0.1 * i) * 10) / 10 184 + const resizeRes = await manipulateAsync( 171 185 localUri, 172 - opts.width, 173 - opts.height, 174 - 'JPEG', 175 - quality, 176 - undefined, 177 - undefined, 178 - undefined, 179 - {mode: opts.mode}, 186 + [{resize: newDimensions}], 187 + { 188 + format: SaveFormat.JPEG, 189 + compress: quality, 190 + }, 180 191 ) 181 - if (resizeRes.size < opts.maxSize) { 192 + 193 + const fileInfo = await getInfoAsync(resizeRes.uri) 194 + if (!fileInfo.exists) { 195 + throw new Error( 196 + 'The image manipulation library failed to create a new image.', 197 + ) 198 + } 199 + 200 + if (fileInfo.size < opts.maxSize) { 201 + safeDeleteAsync(imageRes.uri) 182 202 return { 183 - path: normalizePath(resizeRes.path), 203 + path: normalizePath(resizeRes.uri), 184 204 mime: 'image/jpeg', 185 - size: resizeRes.size, 205 + size: fileInfo.size, 186 206 width: resizeRes.width, 187 207 height: resizeRes.height, 188 208 } 189 209 } else { 190 - safeDeleteAsync(resizeRes.path) 210 + safeDeleteAsync(resizeRes.uri) 191 211 } 192 212 } 193 213 throw new Error( ··· 311 331 safeDeleteAsync(tmpDirUri) 312 332 } 313 333 } 334 + 335 + export function getResizedDimensions(originalDims: { 336 + width: number 337 + height: number 338 + }) { 339 + if ( 340 + originalDims.width <= POST_IMG_MAX.width && 341 + originalDims.height <= POST_IMG_MAX.height 342 + ) { 343 + return originalDims 344 + } 345 + 346 + const ratio = Math.min( 347 + POST_IMG_MAX.width / originalDims.width, 348 + POST_IMG_MAX.height / originalDims.height, 349 + ) 350 + 351 + return { 352 + width: Math.round(originalDims.width * ratio), 353 + height: Math.round(originalDims.height * ratio), 354 + } 355 + }
+13 -13
src/view/com/composer/useExternalLinkFetch.ts
··· 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {logger} from '#/logger' 6 - import {createComposerImage} from '#/state/gallery' 7 - import {useFetchDid} from '#/state/queries/handle' 8 - import {useGetPost} from '#/state/queries/post' 9 - import {useAgent} from '#/state/session' 10 - import * as apilib from 'lib/api/index' 11 - import {POST_IMG_MAX} from 'lib/constants' 5 + import * as apilib from '#/lib/api/index' 6 + import {POST_IMG_MAX} from '#/lib/constants' 12 7 import { 13 8 EmbeddingDisabledError, 14 9 getFeedAsEmbed, 15 10 getListAsEmbed, 16 11 getPostAsQuote, 17 12 getStarterPackAsEmbed, 18 - } from 'lib/link-meta/bsky' 19 - import {getLinkMeta} from 'lib/link-meta/link-meta' 20 - import {resolveShortLink} from 'lib/link-meta/resolve-short-link' 21 - import {downloadAndResize} from 'lib/media/manip' 13 + } from '#/lib/link-meta/bsky' 14 + import {getLinkMeta} from '#/lib/link-meta/link-meta' 15 + import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' 16 + import {downloadAndResize} from '#/lib/media/manip' 22 17 import { 23 18 isBskyCustomFeedUrl, 24 19 isBskyListUrl, ··· 26 21 isBskyStarterPackUrl, 27 22 isBskyStartUrl, 28 23 isShortLink, 29 - } from 'lib/strings/url-helpers' 30 - import {ComposerOpts} from 'state/shell/composer' 24 + } from '#/lib/strings/url-helpers' 25 + import {logger} from '#/logger' 26 + import {createComposerImage} from '#/state/gallery' 27 + import {useFetchDid} from '#/state/queries/handle' 28 + import {useGetPost} from '#/state/queries/post' 29 + import {useAgent} from '#/state/session' 30 + import {ComposerOpts} from '#/state/shell/composer' 31 31 32 32 export function useExternalLinkFetch({ 33 33 setQuote,
-5
yarn.lock
··· 2983 2983 "@babel/helper-validator-identifier" "^7.24.6" 2984 2984 to-fast-properties "^2.0.0" 2985 2985 2986 - "@bam.tech/react-native-image-resizer@^3.0.4": 2987 - version "3.0.5" 2988 - resolved "https://registry.yarnpkg.com/@bam.tech/react-native-image-resizer/-/react-native-image-resizer-3.0.5.tgz#6661ba020de156268f73bdc92fbb93ef86f88a13" 2989 - integrity sha512-u5QGUQGGVZiVCJ786k9/kd7pPRZ6eYfJCYO18myVCH8FbVI7J8b5GT2Svjj2x808DlWeqfaZOOzxPqo27XYvrQ== 2990 - 2991 2986 "@bcoe/v8-coverage@^0.2.3": 2992 2987 version "0.2.3" 2993 2988 resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"