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' 2 import RNFetchBlob from 'rn-fetch-blob' 3 4 import { 5 downloadAndResize, 6 DownloadAndResizeOpts, 7 } from '../../src/lib/media/manip' 8 9 describe('downloadAndResize', () => { 10 const errorSpy = jest.spyOn(global.console, 'error') 11 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 beforeEach(() => { 21 - const mockedCreateResizedImage = 22 - ImageResizer.createResizedImage as jest.Mock 23 - mockedCreateResizedImage.mockResolvedValue(mockResizedImage) 24 }) 25 26 afterEach(() => { ··· 54 'GET', 55 'https://example.com/image.jpg', 56 ) 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'}, 67 ) 68 }) 69 70 it('should return undefined for invalid URI', async () => { ··· 82 expect(result).toBeUndefined() 83 }) 84 85 - it('should return undefined for unsupported file type', async () => { 86 const mockedFetch = RNFetchBlob.fetch as jest.Mock 87 mockedFetch.mockResolvedValueOnce({ 88 path: jest.fn().mockReturnValue('file://downloaded-image'), 89 - info: jest.fn().mockReturnValue({status: 200}), 90 flush: jest.fn(), 91 }) 92 ··· 100 } 101 102 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 - ) 123 }) 124 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 - }) 132 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, 140 } 141 142 - const result = await downloadAndResize(opts) 143 - expect(errorSpy).not.toHaveBeenCalled() 144 - expect(result).toBeUndefined() 145 }) 146 })
··· 1 + import {deleteAsync} from 'expo-file-system' 2 + import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' 3 import RNFetchBlob from 'rn-fetch-blob' 4 5 import { 6 downloadAndResize, 7 DownloadAndResizeOpts, 8 + getResizedDimensions, 9 } from '../../src/lib/media/manip' 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 + 19 describe('downloadAndResize', () => { 20 const errorSpy = jest.spyOn(global.console, 'error') 21 22 beforeEach(() => { 23 + const mockedCreateResizedImage = manipulateAsync as jest.Mock 24 + mockedCreateResizedImage.mockResolvedValue({ 25 + uri: 'file://resized-image.jpg', 26 + ...mockResizedImage, 27 + }) 28 }) 29 30 afterEach(() => { ··· 58 'GET', 59 'https://example.com/image.jpg', 60 ) 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}, 68 ) 69 + expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), { 70 + idempotent: true, 71 + }) 72 }) 73 74 it('should return undefined for invalid URI', async () => { ··· 86 expect(result).toBeUndefined() 87 }) 88 89 + it('should return undefined for non-200 response', async () => { 90 const mockedFetch = RNFetchBlob.fetch as jest.Mock 91 mockedFetch.mockResolvedValueOnce({ 92 path: jest.fn().mockReturnValue('file://downloaded-image'), 93 + info: jest.fn().mockReturnValue({status: 400}), 94 flush: jest.fn(), 95 }) 96 ··· 104 } 105 106 const result = await downloadAndResize(opts) 107 + expect(errorSpy).not.toHaveBeenCalled() 108 + expect(result).toBeUndefined() 109 }) 110 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) 134 135 + const initialDimensionsTwo = { 136 + width: 2000, 137 + height: 4000, 138 } 139 + const resizedDimensionsTwo = getResizedDimensions(initialDimensionsTwo) 140 141 + expect(resizedDimensionsOne).toEqual({ 142 + width: 2000, 143 + height: 1000, 144 + }) 145 + expect(resizedDimensionsTwo).toEqual({ 146 + width: 1000, 147 + height: 2000, 148 + }) 149 }) 150 })
+10 -2
jest/jestSetup.js
··· 42 fetch: jest.fn(), 43 })) 44 45 - jest.mock('@bam.tech/react-native-image-resizer', () => ({ 46 - createResizedImage: jest.fn(), 47 })) 48 49 jest.mock('@segment/analytics-react-native', () => ({
··· 42 fetch: jest.fn(), 43 })) 44 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, 55 })) 56 57 jest.mock('@segment/analytics-react-native', () => ({
-1
package.json
··· 54 }, 55 "dependencies": { 56 "@atproto/api": "^0.13.7", 57 - "@bam.tech/react-native-image-resizer": "^3.0.4", 58 "@braintree/sanitize-url": "^6.0.2", 59 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", 60 "@emoji-mart/react": "^1.1.1",
··· 54 }, 55 "dependencies": { 56 "@atproto/api": "^0.13.7", 57 "@braintree/sanitize-url": "^6.0.2", 58 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", 59 "@emoji-mart/react": "^1.1.1",
+58 -16
src/lib/media/manip.ts
··· 6 copyAsync, 7 deleteAsync, 8 EncodingType, 9 makeDirectoryAsync, 10 StorageAccessFramework, 11 writeAsStringAsync, 12 } from 'expo-file-system' 13 import * as MediaLibrary from 'expo-media-library' 14 import * as Sharing from 'expo-sharing' 15 - import ImageResizer from '@bam.tech/react-native-image-resizer' 16 import {Buffer} from 'buffer' 17 import RNFetchBlob from 'rn-fetch-blob' 18 19 import {logger} from '#/logger' 20 - import {isAndroid, isIOS} from 'platform/detection' 21 import {Dimensions} from './types' 22 23 export async function compressIfNeeded( ··· 165 } 166 167 async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> { 168 for (let i = 0; i < 9; i++) { 169 - const quality = 100 - i * 10 170 - const resizeRes = await ImageResizer.createResizedImage( 171 localUri, 172 - opts.width, 173 - opts.height, 174 - 'JPEG', 175 - quality, 176 - undefined, 177 - undefined, 178 - undefined, 179 - {mode: opts.mode}, 180 ) 181 - if (resizeRes.size < opts.maxSize) { 182 return { 183 - path: normalizePath(resizeRes.path), 184 mime: 'image/jpeg', 185 - size: resizeRes.size, 186 width: resizeRes.width, 187 height: resizeRes.height, 188 } 189 } else { 190 - safeDeleteAsync(resizeRes.path) 191 } 192 } 193 throw new Error( ··· 311 safeDeleteAsync(tmpDirUri) 312 } 313 }
··· 6 copyAsync, 7 deleteAsync, 8 EncodingType, 9 + getInfoAsync, 10 makeDirectoryAsync, 11 StorageAccessFramework, 12 writeAsStringAsync, 13 } from 'expo-file-system' 14 + import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' 15 import * as MediaLibrary from 'expo-media-library' 16 import * as Sharing from 'expo-sharing' 17 import {Buffer} from 'buffer' 18 import RNFetchBlob from 'rn-fetch-blob' 19 20 + import {POST_IMG_MAX} from '#/lib/constants' 21 import {logger} from '#/logger' 22 + import {isAndroid, isIOS} from '#/platform/detection' 23 import {Dimensions} from './types' 24 25 export async function compressIfNeeded( ··· 167 } 168 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 + 181 for (let i = 0; i < 9; i++) { 182 + // nearest 10th 183 + const quality = Math.round((1 - 0.1 * i) * 10) / 10 184 + const resizeRes = await manipulateAsync( 185 localUri, 186 + [{resize: newDimensions}], 187 + { 188 + format: SaveFormat.JPEG, 189 + compress: quality, 190 + }, 191 ) 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) 202 return { 203 + path: normalizePath(resizeRes.uri), 204 mime: 'image/jpeg', 205 + size: fileInfo.size, 206 width: resizeRes.width, 207 height: resizeRes.height, 208 } 209 } else { 210 + safeDeleteAsync(resizeRes.uri) 211 } 212 } 213 throw new Error( ··· 331 safeDeleteAsync(tmpDirUri) 332 } 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 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 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' 12 import { 13 EmbeddingDisabledError, 14 getFeedAsEmbed, 15 getListAsEmbed, 16 getPostAsQuote, 17 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' 22 import { 23 isBskyCustomFeedUrl, 24 isBskyListUrl, ··· 26 isBskyStarterPackUrl, 27 isBskyStartUrl, 28 isShortLink, 29 - } from 'lib/strings/url-helpers' 30 - import {ComposerOpts} from 'state/shell/composer' 31 32 export function useExternalLinkFetch({ 33 setQuote,
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 + import * as apilib from '#/lib/api/index' 6 + import {POST_IMG_MAX} from '#/lib/constants' 7 import { 8 EmbeddingDisabledError, 9 getFeedAsEmbed, 10 getListAsEmbed, 11 getPostAsQuote, 12 getStarterPackAsEmbed, 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' 17 import { 18 isBskyCustomFeedUrl, 19 isBskyListUrl, ··· 21 isBskyStarterPackUrl, 22 isBskyStartUrl, 23 isShortLink, 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 32 export function useExternalLinkFetch({ 33 setQuote,
-5
yarn.lock
··· 2983 "@babel/helper-validator-identifier" "^7.24.6" 2984 to-fast-properties "^2.0.0" 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 "@bcoe/v8-coverage@^0.2.3": 2992 version "0.2.3" 2993 resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
··· 2983 "@babel/helper-validator-identifier" "^7.24.6" 2984 to-fast-properties "^2.0.0" 2985 2986 "@bcoe/v8-coverage@^0.2.3": 2987 version "0.2.3" 2988 resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"