about summary refs log tree commit diff
diff options
context:
space:
mode:
authorOllie H <renahlee@outlook.com>2023-05-30 17:23:55 -0700
committerGitHub <noreply@github.com>2023-05-30 19:23:55 -0500
commit072682dd9f8843787229a98fbeea24161bc0c9b4 (patch)
tree931c55dd298e36e363bb0366f41d671043f091ba
parentdeebe18aaa883d7fcedabd594dda057f991c3026 (diff)
downloadvoidsky-072682dd9f8843787229a98fbeea24161bc0c9b4.tar.zst
Rework scaled dimensions and compression (#737)
* Rework scaled dimensions and compression

* Unbreak image / banner uploads

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
-rw-r--r--src/lib/api/index.ts1
-rw-r--r--src/lib/media/manip.ts44
-rw-r--r--src/lib/media/manip.web.ts19
-rw-r--r--src/lib/media/picker.e2e.tsx4
-rw-r--r--src/lib/media/util.ts17
-rw-r--r--src/state/models/cache/image-sizes.ts1
-rw-r--r--src/state/models/media/gallery.ts24
-rw-r--r--src/state/models/media/image.ts177
-rw-r--r--src/view/com/composer/photos/Gallery.tsx98
-rw-r--r--src/view/com/modals/EditImage.tsx13
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/crop-image/CropImage.tsx11
12 files changed, 175 insertions, 238 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 81b61a444..6235ca343 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -110,6 +110,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     const images: AppBskyEmbedImages.Image[] = []
     for (const image of opts.images) {
       opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
+      await image.compress()
       const path = image.compressed?.path ?? image.path
       const res = await uploadBlob(store, path, 'image/jpeg')
       images.push({
diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts
index 4491010e8..c35953703 100644
--- a/src/lib/media/manip.ts
+++ b/src/lib/media/manip.ts
@@ -6,52 +6,8 @@ import * as RNFS from 'react-native-fs'
 import uuid from 'react-native-uuid'
 import * as Sharing from 'expo-sharing'
 import {Dimensions} from './types'
-import {POST_IMG_MAX} from 'lib/constants'
 import {isAndroid, isIOS} from 'platform/detection'
 
-export async function compressAndResizeImageForPost(
-  image: Image,
-): Promise<Image> {
-  const uri = `file://${image.path}`
-  let resized: Omit<Image, 'mime'>
-
-  for (let i = 0; i < 9; i++) {
-    const quality = 100 - i * 10
-
-    try {
-      resized = await ImageResizer.createResizedImage(
-        uri,
-        POST_IMG_MAX.width,
-        POST_IMG_MAX.height,
-        'JPEG',
-        quality,
-        undefined,
-        undefined,
-        undefined,
-        {mode: 'cover'},
-      )
-    } catch (err) {
-      throw new Error(`Failed to resize: ${err}`)
-    }
-
-    if (resized.size < POST_IMG_MAX.size) {
-      const path = await moveToPermanentPath(resized.path)
-
-      return {
-        path,
-        mime: 'image/jpeg',
-        size: resized.size,
-        height: resized.height,
-        width: resized.width,
-      }
-    }
-  }
-
-  throw new Error(
-    `This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`,
-  )
-}
-
 export async function compressIfNeeded(
   img: Image,
   maxSize: number = 1000000,
diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts
index 85f6b6138..464802c32 100644
--- a/src/lib/media/manip.web.ts
+++ b/src/lib/media/manip.web.ts
@@ -1,25 +1,6 @@
 import {Dimensions} from './types'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {getDataUriSize, blobToDataUri} from './util'
-import {POST_IMG_MAX} from 'lib/constants'
-
-export async function compressAndResizeImageForPost({
-  path,
-  width,
-  height,
-}: {
-  path: string
-  width: number
-  height: number
-}): Promise<RNImage> {
-  // Compression is handled in `doResize` via `quality`
-  return await doResize(path, {
-    width,
-    height,
-    maxSize: POST_IMG_MAX.size,
-    mode: 'stretch',
-  })
-}
 
 export async function compressIfNeeded(
   img: RNImage,
diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx
index e53dc42be..9805c3464 100644
--- a/src/lib/media/picker.e2e.tsx
+++ b/src/lib/media/picker.e2e.tsx
@@ -2,7 +2,7 @@ import {RootStoreModel} from 'state/index'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import RNFS from 'react-native-fs'
 import {CropperOptions} from './types'
-import {compressAndResizeImageForPost} from './manip'
+import {compressIfNeeded} from './manip'
 
 let _imageCounter = 0
 async function getFile() {
@@ -13,7 +13,7 @@ async function getFile() {
       .join('/'),
   )
   const file = files[_imageCounter++ % files.length]
-  return await compressAndResizeImageForPost({
+  return await compressIfNeeded({
     path: file.path,
     mime: 'image/jpeg',
     size: file.size,
diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts
index 75915de6b..73f974874 100644
--- a/src/lib/media/util.ts
+++ b/src/lib/media/util.ts
@@ -1,5 +1,3 @@
-import {Dimensions} from './types'
-
 export function extractDataUriMime(uri: string): string {
   return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
 }
@@ -10,21 +8,6 @@ export function getDataUriSize(uri: string): number {
   return Math.round((uri.length * 3) / 4)
 }
 
-export function scaleDownDimensions(
-  dim: Dimensions,
-  max: Dimensions,
-): Dimensions {
-  if (dim.width < max.width && dim.height < max.height) {
-    return dim
-  }
-  const wScale = dim.width > max.width ? max.width / dim.width : 1
-  const hScale = dim.height > max.height ? max.height / dim.height : 1
-  if (wScale < hScale) {
-    return {width: dim.width * wScale, height: dim.height * wScale}
-  }
-  return {width: dim.width * hScale, height: dim.height * hScale}
-}
-
 export function isUriImage(uri: string) {
   return /\.(jpg|jpeg|png).*$/.test(uri)
 }
diff --git a/src/state/models/cache/image-sizes.ts b/src/state/models/cache/image-sizes.ts
index bbfb9612b..c30a68f4d 100644
--- a/src/state/models/cache/image-sizes.ts
+++ b/src/state/models/cache/image-sizes.ts
@@ -16,6 +16,7 @@ export class ImageSizesCache {
     if (Dimensions) {
       return Dimensions
     }
+
     const prom =
       this.activeRequests.get(uri) ||
       new Promise<Dimensions>(resolve => {
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
index 67f8d2ea1..52ef8f375 100644
--- a/src/state/models/media/gallery.ts
+++ b/src/state/models/media/gallery.ts
@@ -4,7 +4,6 @@ import {ImageModel} from './image'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {openPicker} from 'lib/media/picker'
 import {getImageDim} from 'lib/media/manip'
-import {getDataUriSize} from 'lib/media/util'
 import {isNative} from 'platform/detection'
 
 export class GalleryModel {
@@ -24,13 +23,7 @@ export class GalleryModel {
     return this.images.length
   }
 
-  get paths() {
-    return this.images.map(image =>
-      image.compressed === undefined ? image.path : image.compressed.path,
-    )
-  }
-
-  async add(image_: RNImage) {
+  async add(image_: Omit<RNImage, 'size'>) {
     if (this.size >= 4) {
       return
     }
@@ -39,15 +32,9 @@ export class GalleryModel {
     if (!this.images.some(i => i.path === image_.path)) {
       const image = new ImageModel(this.rootStore, image_)
 
-      if (!isNative) {
-        await image.manipulate({})
-      } else {
-        await image.compress()
-      }
-
-      runInAction(() => {
-        this.images.push(image)
-      })
+      // Initial resize
+      image.manipulate({})
+      this.images.push(image)
     }
   }
 
@@ -70,11 +57,10 @@ export class GalleryModel {
 
     const {width, height} = await getImageDim(uri)
 
-    const image: RNImage = {
+    const image = {
       path: uri,
       height,
       width,
-      size: getDataUriSize(uri),
       mime: 'image/jpeg',
     }
 
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index 6edf88d9d..e524c49de 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -3,14 +3,11 @@ import {RootStoreModel} from 'state/index'
 import {makeAutoObservable, runInAction} from 'mobx'
 import {POST_IMG_MAX} from 'lib/constants'
 import * as ImageManipulator from 'expo-image-manipulator'
-import {getDataUriSize, scaleDownDimensions} from 'lib/media/util'
+import {getDataUriSize} from 'lib/media/util'
 import {openCropper} from 'lib/media/picker'
 import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
 import {Position} from 'react-avatar-editor'
-import {compressAndResizeImageForPost} from 'lib/media/manip'
-
-// TODO: EXIF embed
-// Cases to consider: ExternalEmbed
+import {Dimensions} from 'lib/media/types'
 
 export interface ImageManipulationAttributes {
   aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
@@ -21,17 +18,16 @@ export interface ImageManipulationAttributes {
   flipVertical?: boolean
 }
 
-export class ImageModel implements RNImage {
+const MAX_IMAGE_SIZE_IN_BYTES = 976560
+
+export class ImageModel implements Omit<RNImage, 'size'> {
   path: string
   mime = 'image/jpeg'
   width: number
   height: number
-  size: number
   altText = ''
   cropped?: RNImage = undefined
   compressed?: RNImage = undefined
-  scaledWidth: number = POST_IMG_MAX.width
-  scaledHeight: number = POST_IMG_MAX.height
 
   // Web manipulation
   prev?: RNImage
@@ -44,7 +40,7 @@ export class ImageModel implements RNImage {
   }
   prevAttributes: ImageManipulationAttributes = {}
 
-  constructor(public rootStore: RootStoreModel, image: RNImage) {
+  constructor(public rootStore: RootStoreModel, image: Omit<RNImage, 'size'>) {
     makeAutoObservable(this, {
       rootStore: false,
     })
@@ -52,19 +48,8 @@ export class ImageModel implements RNImage {
     this.path = image.path
     this.width = image.width
     this.height = image.height
-    this.size = image.size
-    this.calcScaledDimensions()
   }
 
-  // TODO: Revisit compression factor due to updated sizing with zoom
-  // get compressionFactor() {
-  //   const MAX_IMAGE_SIZE_IN_BYTES = 976560
-
-  //   return this.size < MAX_IMAGE_SIZE_IN_BYTES
-  //     ? 1
-  //     : MAX_IMAGE_SIZE_IN_BYTES / this.size
-  // }
-
   setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
     this.attributes.aspectRatio = aspectRatio
   }
@@ -93,8 +78,24 @@ export class ImageModel implements RNImage {
     }
   }
 
-  getDisplayDimensions(
-    as: ImageManipulationAttributes['aspectRatio'] = '1:1',
+  getUploadDimensions(
+    dimensions: Dimensions,
+    maxDimensions: Dimensions = POST_IMG_MAX,
+    as: ImageManipulationAttributes['aspectRatio'] = 'None',
+  ) {
+    const {width, height} = dimensions
+    const {width: maxWidth, height: maxHeight} = maxDimensions
+
+    return width < maxWidth && height < maxHeight
+      ? {
+          width,
+          height,
+        }
+      : this.getResizedDimensions(as, POST_IMG_MAX.width)
+  }
+
+  getResizedDimensions(
+    as: ImageManipulationAttributes['aspectRatio'] = 'None',
     maxSide: number,
   ) {
     const ratioMultiplier = this.ratioMultipliers[as]
@@ -119,59 +120,70 @@ export class ImageModel implements RNImage {
     }
   }
 
-  calcScaledDimensions() {
-    const {width, height} = scaleDownDimensions(
-      {width: this.width, height: this.height},
-      POST_IMG_MAX,
-    )
-    this.scaledWidth = width
-    this.scaledHeight = height
-  }
-
   async setAltText(altText: string) {
     this.altText = altText
   }
 
-  // Only for mobile
+  // Only compress prior to upload
+  async compress() {
+    for (let i = 10; i > 0; i--) {
+      // Float precision
+      const factor = Math.round(i) / 10
+      const compressed = await ImageManipulator.manipulateAsync(
+        this.cropped?.path ?? this.path,
+        undefined,
+        {
+          compress: factor,
+          base64: true,
+          format: SaveFormat.JPEG,
+        },
+      )
+
+      if (compressed.base64 !== undefined) {
+        const size = getDataUriSize(compressed.base64)
+
+        if (size < MAX_IMAGE_SIZE_IN_BYTES) {
+          runInAction(() => {
+            this.compressed = {
+              mime: 'image/jpeg',
+              path: compressed.uri,
+              size,
+              ...compressed,
+            }
+          })
+          return
+        }
+      }
+    }
+
+    // Compression fails when removing redundant information is not possible.
+    // This can be tested with images that have high variance in noise.
+    throw new Error('Failed to compress image')
+  }
+
+  // Mobile
   async crop() {
     try {
+      // openCropper requires an output width and height hence
+      // getting upload dimensions before cropping is necessary.
+      const {width, height} = this.getUploadDimensions({
+        width: this.width,
+        height: this.height,
+      })
+
       const cropped = await openCropper(this.rootStore, {
         mediaType: 'photo',
         path: this.path,
         freeStyleCropEnabled: true,
-        width: this.scaledWidth,
-        height: this.scaledHeight,
-      })
-      runInAction(() => {
-        this.cropped = cropped
-        this.compress()
-      })
-    } catch (err) {
-      this.rootStore.log.error('Failed to crop photo', err)
-    }
-  }
-
-  async compress() {
-    try {
-      const {width, height} = scaleDownDimensions(
-        this.cropped
-          ? {width: this.cropped.width, height: this.cropped.height}
-          : {width: this.width, height: this.height},
-        POST_IMG_MAX,
-      )
-
-      // TODO: Revisit this - currently iOS uses this as well
-      const compressed = await compressAndResizeImageForPost({
-        ...(this.cropped === undefined ? this : this.cropped),
         width,
         height,
       })
 
       runInAction(() => {
-        this.compressed = compressed
+        this.cropped = cropped
       })
     } catch (err) {
-      this.rootStore.log.error('Failed to compress photo', err)
+      this.rootStore.log.error('Failed to crop photo', err)
     }
   }
 
@@ -181,6 +193,9 @@ export class ImageModel implements RNImage {
       crop?: ActionCrop['crop']
     } & ImageManipulationAttributes,
   ) {
+    let uploadWidth: number | undefined
+    let uploadHeight: number | undefined
+
     const {aspectRatio, crop, position, scale} = attributes
     const modifiers = []
 
@@ -197,14 +212,34 @@ export class ImageModel implements RNImage {
     }
 
     if (crop !== undefined) {
+      const croppedHeight = crop.height * this.height
+      const croppedWidth = crop.width * this.width
       modifiers.push({
         crop: {
           originX: crop.originX * this.width,
           originY: crop.originY * this.height,
-          height: crop.height * this.height,
-          width: crop.width * this.width,
+          height: croppedHeight,
+          width: croppedWidth,
         },
       })
+
+      const uploadDimensions = this.getUploadDimensions(
+        {width: croppedWidth, height: croppedHeight},
+        POST_IMG_MAX,
+        aspectRatio,
+      )
+
+      uploadWidth = uploadDimensions.width
+      uploadHeight = uploadDimensions.height
+    } else {
+      const uploadDimensions = this.getUploadDimensions(
+        {width: this.width, height: this.height},
+        POST_IMG_MAX,
+        aspectRatio,
+      )
+
+      uploadWidth = uploadDimensions.width
+      uploadHeight = uploadDimensions.height
     }
 
     if (scale !== undefined) {
@@ -222,36 +257,40 @@ export class ImageModel implements RNImage {
     const ratioMultiplier =
       this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
 
-    const MAX_SIDE = 2000
-
     const result = await ImageManipulator.manipulateAsync(
       this.path,
       [
         ...modifiers,
-        {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}},
+        {
+          resize:
+            ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight},
+        },
       ],
       {
-        compress: 0.9,
+        base64: true,
         format: SaveFormat.JPEG,
       },
     )
 
     runInAction(() => {
-      this.compressed = {
+      this.cropped = {
         mime: 'image/jpeg',
         path: result.uri,
-        size: getDataUriSize(result.uri),
+        size:
+          result.base64 !== undefined
+            ? getDataUriSize(result.base64)
+            : MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails
         ...result,
       }
     })
   }
 
-  resetCompressed() {
+  resetCropped() {
     this.manipulate({})
   }
 
   previous() {
-    this.compressed = this.prev
+    this.cropped = this.prev
     this.attributes = this.prevAttributes
   }
 }
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 436824952..f46c05333 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -104,63 +104,61 @@ export const Gallery = observer(function ({gallery}: Props) {
 
   return !gallery.isEmpty ? (
     <View testID="selectedPhotosView" style={styles.gallery}>
-      {gallery.images.map(image =>
-        image.compressed !== undefined ? (
-          <View key={`selected-image-${image.path}`} style={[imageStyle]}>
+      {gallery.images.map(image => (
+        <View key={`selected-image-${image.path}`} style={[imageStyle]}>
+          <TouchableOpacity
+            testID="altTextButton"
+            accessibilityRole="button"
+            accessibilityLabel="Add alt text"
+            accessibilityHint=""
+            onPress={() => {
+              handleAddImageAltText(image)
+            }}
+            style={imageControlLabelStyle}>
+            <Text style={styles.imageControlTextContent}>ALT</Text>
+          </TouchableOpacity>
+          <View style={imageControlsSubgroupStyle}>
             <TouchableOpacity
-              testID="altTextButton"
+              testID="editPhotoButton"
               accessibilityRole="button"
-              accessibilityLabel="Add alt text"
+              accessibilityLabel="Edit image"
               accessibilityHint=""
               onPress={() => {
-                handleAddImageAltText(image)
+                handleEditPhoto(image)
               }}
-              style={imageControlLabelStyle}>
-              <Text style={styles.imageControlTextContent}>ALT</Text>
+              style={styles.imageControl}>
+              <FontAwesomeIcon
+                icon="pen"
+                size={12}
+                style={{color: colors.white}}
+              />
+            </TouchableOpacity>
+            <TouchableOpacity
+              testID="removePhotoButton"
+              accessibilityRole="button"
+              accessibilityLabel="Remove image"
+              accessibilityHint=""
+              onPress={() => handleRemovePhoto(image)}
+              style={styles.imageControl}>
+              <FontAwesomeIcon
+                icon="xmark"
+                size={16}
+                style={{color: colors.white}}
+              />
             </TouchableOpacity>
-            <View style={imageControlsSubgroupStyle}>
-              <TouchableOpacity
-                testID="editPhotoButton"
-                accessibilityRole="button"
-                accessibilityLabel="Edit image"
-                accessibilityHint=""
-                onPress={() => {
-                  handleEditPhoto(image)
-                }}
-                style={styles.imageControl}>
-                <FontAwesomeIcon
-                  icon="pen"
-                  size={12}
-                  style={{color: colors.white}}
-                />
-              </TouchableOpacity>
-              <TouchableOpacity
-                testID="removePhotoButton"
-                accessibilityRole="button"
-                accessibilityLabel="Remove image"
-                accessibilityHint=""
-                onPress={() => handleRemovePhoto(image)}
-                style={styles.imageControl}>
-                <FontAwesomeIcon
-                  icon="xmark"
-                  size={16}
-                  style={{color: colors.white}}
-                />
-              </TouchableOpacity>
-            </View>
-
-            <Image
-              testID="selectedPhotoImage"
-              style={[styles.image, imageStyle] as ImageStyle}
-              source={{
-                uri: image.compressed.path,
-              }}
-              accessible={true}
-              accessibilityIgnoresInvertColors
-            />
           </View>
-        ) : null,
-      )}
+
+          <Image
+            testID="selectedPhotoImage"
+            style={[styles.image, imageStyle] as ImageStyle}
+            source={{
+              uri: image.cropped?.path ?? image.path,
+            }}
+            accessible={true}
+            accessibilityIgnoresInvertColors
+          />
+        </View>
+      ))}
     </View>
   ) : null
 })
diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx
index eab472a78..09ae01943 100644
--- a/src/view/com/modals/EditImage.tsx
+++ b/src/view/com/modals/EditImage.tsx
@@ -118,9 +118,9 @@ export const Component = observer(function ({image, gallery}: Props) {
   )
 
   useEffect(() => {
-    image.prev = image.compressed
+    image.prev = image.cropped
     image.prevAttributes = image.attributes
-    image.resetCompressed()
+    image.resetCropped()
   }, [image])
 
   const onCloseModal = useCallback(() => {
@@ -152,7 +152,7 @@ export const Component = observer(function ({image, gallery}: Props) {
         : {}),
     })
 
-    image.prev = image.compressed
+    image.prev = image.cropped
     image.prevAttributes = image.attributes
     onCloseModal()
   }, [altText, image, position, scale, onCloseModal])
@@ -168,8 +168,7 @@ export const Component = observer(function ({image, gallery}: Props) {
     }
   }, [])
 
-  // Prevents preliminary flash when transformations are being applied
-  if (image.compressed === undefined) {
+  if (image.cropped === undefined) {
     return null
   }
 
@@ -177,7 +176,7 @@ export const Component = observer(function ({image, gallery}: Props) {
     windowDimensions.width > 500 ? 410 : windowDimensions.width - 80
   const sideLength = isDesktopWeb ? 300 : computedWidth
 
-  const dimensions = image.getDisplayDimensions(aspectRatio, sideLength)
+  const dimensions = image.getResizedDimensions(aspectRatio, sideLength)
   const imgContainerStyles = {width: sideLength, height: sideLength}
 
   const imgControlStyles = {
@@ -196,7 +195,7 @@ export const Component = observer(function ({image, gallery}: Props) {
             <ImageEditor
               ref={editorRef}
               style={styles.imgEditor}
-              image={image.compressed.path}
+              image={image.cropped.path}
               scale={scale}
               border={0}
               position={position}
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 08ee74b02..060129099 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -15,6 +15,7 @@ import * as RepostModal from './Repost'
 import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
 import * as ListAddRemoveUserModal from './ListAddRemoveUser'
 import * as AltImageModal from './AltImage'
+import * as EditImageModal from './AltImage'
 import * as ReportAccountModal from './ReportAccount'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as ChangeHandleModal from './ChangeHandle'
@@ -83,6 +84,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'alt-text-image') {
     snapPoints = AltImageModal.snapPoints
     element = <AltImageModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'edit-image') {
+    snapPoints = AltImageModal.snapPoints
+    element = <EditImageModal.Component {...activeModal} />
   } else if (activeModal?.name === 'change-handle') {
     snapPoints = ChangeHandleModal.snapPoints
     element = <ChangeHandleModal.Component {...activeModal} />
diff --git a/src/view/com/modals/crop-image/CropImage.tsx b/src/view/com/modals/crop-image/CropImage.tsx
deleted file mode 100644
index 9ac3f277f..000000000
--- a/src/view/com/modals/crop-image/CropImage.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * NOTE
- * This modal is used only in the web build
- * Native uses a third-party library
- */
-
-export const snapPoints = ['0%']
-
-export function Component() {
-  return null
-}