about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json3
-rw-r--r--src/lib/api/index.ts53
-rw-r--r--src/lib/media/picker.shared.ts2
-rw-r--r--src/state/gallery.ts299
-rw-r--r--src/state/modals/index.tsx13
-rw-r--r--src/state/models/media/gallery.ts110
-rw-r--r--src/state/models/media/image.e2e.ts146
-rw-r--r--src/state/models/media/image.ts310
-rw-r--r--src/state/shell/composer/index.tsx7
-rw-r--r--src/view/com/composer/Composer.tsx67
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx2
-rw-r--r--src/view/com/composer/GifAltText.tsx2
-rw-r--r--src/view/com/composer/photos/Gallery.tsx336
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx13
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx21
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts7
-rw-r--r--src/view/com/modals/AltImage.tsx29
-rw-r--r--src/view/com/modals/EditImage.tsx380
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx9
-rw-r--r--src/view/shell/Composer.ios.tsx7
-rw-r--r--src/view/shell/Composer.tsx15
-rw-r--r--yarn.lock15
23 files changed, 594 insertions, 1256 deletions
diff --git a/package.json b/package.json
index d3a94eb8d..117fc0b19 100644
--- a/package.json
+++ b/package.json
@@ -161,9 +161,6 @@
     "lodash.set": "^4.3.2",
     "lodash.shuffle": "^4.2.0",
     "lodash.throttle": "^4.1.1",
-    "mobx": "^6.6.1",
-    "mobx-react-lite": "^3.4.0",
-    "mobx-utils": "^6.0.6",
     "nanoid": "^5.0.5",
     "normalize-url": "^8.0.0",
     "patch-package": "^6.5.1",
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index f6537e3d1..1e51c7f25 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -14,6 +14,7 @@ import {
 } from '@atproto/api'
 
 import {logger} from '#/logger'
+import {ComposerImage, compressImage} from '#/state/gallery'
 import {writePostgateRecord} from '#/state/queries/postgate'
 import {
   createThreadgateRecord,
@@ -23,10 +24,7 @@ import {
 } from '#/state/queries/threadgate'
 import {isNetworkError} from 'lib/strings/errors'
 import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
-import {isNative} from 'platform/detection'
-import {ImageModel} from 'state/models/media/image'
 import {LinkMeta} from '../link-meta/link-meta'
-import {safeDeleteAsync} from '../media/manip'
 import {uploadBlob} from './upload-blob'
 
 export {uploadBlob}
@@ -36,7 +34,7 @@ export interface ExternalEmbedDraft {
   isLoading: boolean
   meta?: LinkMeta
   embed?: AppBskyEmbedRecord.Main
-  localThumb?: ImageModel
+  localThumb?: ComposerImage
 }
 
 interface PostOpts {
@@ -53,7 +51,7 @@ interface PostOpts {
     aspectRatio?: AppBskyEmbedDefs.AspectRatio
   }
   extLink?: ExternalEmbedDraft
-  images?: ImageModel[]
+  images?: ComposerImage[]
   labels?: string[]
   threadgate: ThreadgateAllowUISetting[]
   postgate: AppBskyFeedPostgate.Record
@@ -99,18 +97,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
     const images: AppBskyEmbedImages.Image[] = []
     for (const image of opts.images) {
       opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
+
       logger.debug(`Compressing image`)
-      await image.compress()
-      const path = image.compressed?.path ?? image.path
-      const {width, height} = image.compressed || image
+      const {path, width, height, mime} = await compressImage(image)
+
       logger.debug(`Uploading image`)
-      const res = await uploadBlob(agent, path, 'image/jpeg')
-      if (isNative) {
-        safeDeleteAsync(path)
-      }
+      const res = await uploadBlob(agent, path, mime)
+
       images.push({
         image: res.data.blob,
-        alt: image.altText ?? '',
+        alt: image.alt,
         aspectRatio: {width, height},
       })
     }
@@ -175,32 +171,11 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
       let thumb
       if (opts.extLink.localThumb) {
         opts.onStateChange?.('Uploading link thumbnail...')
-        let encoding
-        if (opts.extLink.localThumb.mime) {
-          encoding = opts.extLink.localThumb.mime
-        } else if (opts.extLink.localThumb.path.endsWith('.png')) {
-          encoding = 'image/png'
-        } else if (
-          opts.extLink.localThumb.path.endsWith('.jpeg') ||
-          opts.extLink.localThumb.path.endsWith('.jpg')
-        ) {
-          encoding = 'image/jpeg'
-        } else {
-          logger.warn('Unexpected image format for thumbnail, skipping', {
-            thumbnail: opts.extLink.localThumb.path,
-          })
-        }
-        if (encoding) {
-          const thumbUploadRes = await uploadBlob(
-            agent,
-            opts.extLink.localThumb.path,
-            encoding,
-          )
-          thumb = thumbUploadRes.data.blob
-          if (isNative) {
-            safeDeleteAsync(opts.extLink.localThumb.path)
-          }
-        }
+
+        const {path, mime} = opts.extLink.localThumb.source
+        const res = await uploadBlob(agent, path, mime)
+
+        thumb = res.data.blob
       }
 
       if (opts.quote) {
diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts
index 9146cd778..b959ce8be 100644
--- a/src/lib/media/picker.shared.ts
+++ b/src/lib/media/picker.shared.ts
@@ -28,7 +28,7 @@ export async function openPicker(opts?: ImagePickerOptions) {
       return false
     })
     .map(image => ({
-      mime: 'image/jpeg',
+      mime: image.mimeType || 'image/jpeg',
       height: image.height,
       width: image.width,
       path: image.uri,
diff --git a/src/state/gallery.ts b/src/state/gallery.ts
new file mode 100644
index 000000000..f4c8b712e
--- /dev/null
+++ b/src/state/gallery.ts
@@ -0,0 +1,299 @@
+import {
+  cacheDirectory,
+  deleteAsync,
+  makeDirectoryAsync,
+  moveAsync,
+} from 'expo-file-system'
+import {
+  Action,
+  ActionCrop,
+  manipulateAsync,
+  SaveFormat,
+} from 'expo-image-manipulator'
+import {nanoid} from 'nanoid/non-secure'
+
+import {POST_IMG_MAX} from '#/lib/constants'
+import {getImageDim} from '#/lib/media/manip'
+import {openCropper} from '#/lib/media/picker'
+import {getDataUriSize} from '#/lib/media/util'
+import {isIOS, isNative} from '#/platform/detection'
+
+export type ImageTransformation = {
+  crop?: ActionCrop['crop']
+}
+
+export type ImageMeta = {
+  path: string
+  width: number
+  height: number
+  mime: string
+}
+
+export type ImageSource = ImageMeta & {
+  id: string
+}
+
+type ComposerImageBase = {
+  alt: string
+  source: ImageSource
+}
+type ComposerImageWithoutTransformation = ComposerImageBase & {
+  transformed?: undefined
+  manips?: undefined
+}
+type ComposerImageWithTransformation = ComposerImageBase & {
+  transformed: ImageMeta
+  manips?: ImageTransformation
+}
+
+export type ComposerImage =
+  | ComposerImageWithoutTransformation
+  | ComposerImageWithTransformation
+
+let _imageCacheDirectory: string
+
+function getImageCacheDirectory(): string | null {
+  if (isNative) {
+    return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer'))
+  }
+
+  return null
+}
+
+export async function createComposerImage(
+  raw: ImageMeta,
+): Promise<ComposerImageWithoutTransformation> {
+  return {
+    alt: '',
+    source: {
+      id: nanoid(),
+      path: await moveIfNecessary(raw.path),
+      width: raw.width,
+      height: raw.height,
+      mime: raw.mime,
+    },
+  }
+}
+
+export type InitialImage = {
+  uri: string
+  width: number
+  height: number
+  altText?: string
+}
+
+export function createInitialImages(
+  uris: InitialImage[] = [],
+): ComposerImageWithoutTransformation[] {
+  return uris.map(({uri, width, height, altText = ''}) => {
+    return {
+      alt: altText,
+      source: {
+        id: nanoid(),
+        path: uri,
+        width: width,
+        height: height,
+        mime: 'image/jpeg',
+      },
+    }
+  })
+}
+
+export async function pasteImage(
+  uri: string,
+): Promise<ComposerImageWithoutTransformation> {
+  const {width, height} = await getImageDim(uri)
+  const match = /^data:(.+?);/.exec(uri)
+
+  return {
+    alt: '',
+    source: {
+      id: nanoid(),
+      path: uri,
+      width: width,
+      height: height,
+      mime: match ? match[1] : 'image/jpeg',
+    },
+  }
+}
+
+export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
+  if (!isNative) {
+    return img
+  }
+
+  // NOTE
+  // on ios, react-native-image-crop-picker gives really bad quality
+  // without specifying width and height. on android, however, the
+  // crop stretches incorrectly if you do specify it. these are
+  // both separate bugs in the library. we deal with that by
+  // providing width & height for ios only
+  // -prf
+
+  const source = img.source
+  const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
+
+  // @todo: we're always passing the original image here, does image-cropper
+  // allows for setting initial crop dimensions? -mary
+  try {
+    const cropped = await openCropper({
+      mediaType: 'photo',
+      path: source.path,
+      freeStyleCropEnabled: true,
+      ...(isIOS ? {width: w, height: h} : {}),
+    })
+
+    return {
+      alt: img.alt,
+      source: source,
+      transformed: {
+        path: await moveIfNecessary(cropped.path),
+        width: cropped.width,
+        height: cropped.height,
+        mime: cropped.mime,
+      },
+    }
+  } catch (e) {
+    if (e instanceof Error && e.message.includes('User cancelled')) {
+      return img
+    }
+
+    throw e
+  }
+}
+
+export async function manipulateImage(
+  img: ComposerImage,
+  trans: ImageTransformation,
+): Promise<ComposerImage> {
+  const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}]
+
+  const actions = rawActions.filter((a): a is Action => a !== undefined)
+
+  if (actions.length === 0) {
+    if (img.transformed === undefined) {
+      return img
+    }
+
+    return {alt: img.alt, source: img.source}
+  }
+
+  const source = img.source
+  const result = await manipulateAsync(source.path, actions, {
+    format: SaveFormat.PNG,
+  })
+
+  return {
+    alt: img.alt,
+    source: img.source,
+    transformed: {
+      path: await moveIfNecessary(result.uri),
+      width: result.width,
+      height: result.height,
+      mime: 'image/png',
+    },
+    manips: trans,
+  }
+}
+
+export function resetImageManipulation(
+  img: ComposerImage,
+): ComposerImageWithoutTransformation {
+  if (img.transformed !== undefined) {
+    return {alt: img.alt, source: img.source}
+  }
+
+  return img
+}
+
+export async function compressImage(img: ComposerImage): Promise<ImageMeta> {
+  const source = img.transformed || img.source
+
+  const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
+  const cacheDir = isNative && getImageCacheDirectory()
+
+  for (let i = 10; i > 0; i--) {
+    // Float precision
+    const factor = i / 10
+
+    const res = await manipulateAsync(
+      source.path,
+      [{resize: {width: w, height: h}}],
+      {
+        compress: factor,
+        format: SaveFormat.JPEG,
+        base64: true,
+      },
+    )
+
+    const base64 = res.base64
+
+    if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) {
+      return {
+        path: await moveIfNecessary(res.uri),
+        width: res.width,
+        height: res.height,
+        mime: 'image/jpeg',
+      }
+    }
+
+    if (cacheDir) {
+      await deleteAsync(res.uri)
+    }
+  }
+
+  throw new Error(`Unable to compress image`)
+}
+
+async function moveIfNecessary(from: string) {
+  const cacheDir = isNative && getImageCacheDirectory()
+
+  if (cacheDir && from.startsWith(cacheDir)) {
+    const to = joinPath(cacheDir, nanoid(36))
+
+    await makeDirectoryAsync(cacheDir, {intermediates: true})
+    await moveAsync({from, to})
+
+    return to
+  }
+
+  return from
+}
+
+/** Purge files that were created to accomodate image manipulation */
+export async function purgeTemporaryImageFiles() {
+  const cacheDir = isNative && getImageCacheDirectory()
+
+  if (cacheDir) {
+    await deleteAsync(cacheDir, {idempotent: true})
+    await makeDirectoryAsync(cacheDir)
+  }
+}
+
+function joinPath(a: string, b: string) {
+  if (a.endsWith('/')) {
+    if (b.startsWith('/')) {
+      return a.slice(0, -1) + b
+    }
+    return a + b
+  } else if (b.startsWith('/')) {
+    return a + b
+  }
+  return a + '/' + b
+}
+
+function containImageRes(
+  w: number,
+  h: number,
+  {width: maxW, height: maxH}: {width: number; height: number},
+): [width: number, height: number] {
+  let scale = 1
+
+  if (w > maxW || h > maxH) {
+    scale = w > h ? maxW / w : maxH / h
+    w = Math.floor(w * scale)
+    h = Math.floor(h * scale)
+  }
+
+  return [w, h]
+}
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 529dc5590..467853a25 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -3,8 +3,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
 import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
 
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-import {GalleryModel} from '#/state/models/media/gallery'
-import {ImageModel} from '#/state/models/media/image'
+import {ComposerImage} from '../gallery'
 
 export interface EditProfileModal {
   name: 'edit-profile'
@@ -37,12 +36,6 @@ export interface ListAddRemoveUsersModal {
   ) => void
 }
 
-export interface EditImageModal {
-  name: 'edit-image'
-  image: ImageModel
-  gallery: GalleryModel
-}
-
 export interface CropImageModal {
   name: 'crop-image'
   uri: string
@@ -52,7 +45,8 @@ export interface CropImageModal {
 
 export interface AltTextImageModal {
   name: 'alt-text-image'
-  image: ImageModel
+  image: ComposerImage
+  onChange: (next: ComposerImage) => void
 }
 
 export interface DeleteAccountModal {
@@ -139,7 +133,6 @@ export type Modal =
   // Posts
   | AltTextImageModal
   | CropImageModal
-  | EditImageModal
   | SelfLabelModal
 
   // Bluesky access
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
deleted file mode 100644
index 828905002..000000000
--- a/src/state/models/media/gallery.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-
-import {getImageDim} from 'lib/media/manip'
-import {openPicker} from 'lib/media/picker'
-import {ImageInitOptions, ImageModel} from './image'
-
-interface InitialImageUri {
-  uri: string
-  width: number
-  height: number
-  altText?: string
-}
-
-export class GalleryModel {
-  images: ImageModel[] = []
-
-  constructor(uris?: InitialImageUri[]) {
-    makeAutoObservable(this)
-
-    if (uris) {
-      this.addFromUris(uris)
-    }
-  }
-
-  get isEmpty() {
-    return this.size === 0
-  }
-
-  get size() {
-    return this.images.length
-  }
-
-  get needsAltText() {
-    return this.images.some(image => image.altText.trim() === '')
-  }
-
-  *add(image_: ImageInitOptions) {
-    if (this.size >= 4) {
-      return
-    }
-
-    // Temporarily enforce uniqueness but can eventually also use index
-    if (!this.images.some(i => i.path === image_.path)) {
-      const image = new ImageModel(image_)
-
-      // Initial resize
-      image.manipulate({})
-      this.images.push(image)
-    }
-  }
-
-  async paste(uri: string) {
-    if (this.size >= 4) {
-      return
-    }
-
-    const {width, height} = await getImageDim(uri)
-
-    const image = {
-      path: uri,
-      height,
-      width,
-    }
-
-    runInAction(() => {
-      this.add(image)
-    })
-  }
-
-  setAltText(image: ImageModel, altText: string) {
-    image.setAltText(altText)
-  }
-
-  crop(image: ImageModel) {
-    image.crop()
-  }
-
-  remove(image: ImageModel) {
-    const index = this.images.findIndex(image_ => image_.path === image.path)
-    this.images.splice(index, 1)
-  }
-
-  async previous(image: ImageModel) {
-    image.previous()
-  }
-
-  async pick() {
-    const images = await openPicker({
-      selectionLimit: 4 - this.size,
-      allowsMultipleSelection: true,
-    })
-
-    return await Promise.all(
-      images.map(image => {
-        this.add(image)
-      }),
-    )
-  }
-
-  async addFromUris(uris: InitialImageUri[]) {
-    for (const uriObj of uris) {
-      this.add({
-        height: uriObj.height,
-        width: uriObj.width,
-        path: uriObj.uri,
-        altText: uriObj.altText,
-      })
-    }
-  }
-}
diff --git a/src/state/models/media/image.e2e.ts b/src/state/models/media/image.e2e.ts
deleted file mode 100644
index ccabd5047..000000000
--- a/src/state/models/media/image.e2e.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import {makeAutoObservable} from 'mobx'
-import {POST_IMG_MAX} from 'lib/constants'
-import {ActionCrop} from 'expo-image-manipulator'
-import {Position} from 'react-avatar-editor'
-import {Dimensions} from 'lib/media/types'
-
-export interface ImageManipulationAttributes {
-  aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
-  rotate?: number
-  scale?: number
-  position?: Position
-  flipHorizontal?: boolean
-  flipVertical?: boolean
-}
-
-export class ImageModel implements Omit<RNImage, 'size'> {
-  path: string
-  mime = 'image/jpeg'
-  width: number
-  height: number
-  altText = ''
-  cropped?: RNImage = undefined
-  compressed?: RNImage = undefined
-
-  // Web manipulation
-  prev?: RNImage
-  attributes: ImageManipulationAttributes = {
-    aspectRatio: 'None',
-    scale: 1,
-    flipHorizontal: false,
-    flipVertical: false,
-    rotate: 0,
-  }
-  prevAttributes: ImageManipulationAttributes = {}
-
-  constructor(image: Omit<RNImage, 'size'>) {
-    makeAutoObservable(this)
-
-    this.path = image.path
-    this.width = image.width
-    this.height = image.height
-  }
-
-  setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
-    this.attributes.aspectRatio = aspectRatio
-  }
-
-  setRotate(degrees: number) {
-    this.attributes.rotate = degrees
-    this.manipulate({})
-  }
-
-  flipVertical() {
-    this.attributes.flipVertical = !this.attributes.flipVertical
-    this.manipulate({})
-  }
-
-  flipHorizontal() {
-    this.attributes.flipHorizontal = !this.attributes.flipHorizontal
-    this.manipulate({})
-  }
-
-  get ratioMultipliers() {
-    return {
-      '4:3': 4 / 3,
-      '1:1': 1,
-      '3:4': 3 / 4,
-      None: this.width / this.height,
-    }
-  }
-
-  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]
-
-    if (ratioMultiplier === 1) {
-      return {
-        height: maxSide,
-        width: maxSide,
-      }
-    }
-
-    if (ratioMultiplier < 1) {
-      return {
-        width: maxSide * ratioMultiplier,
-        height: maxSide,
-      }
-    }
-
-    return {
-      width: maxSide,
-      height: maxSide / ratioMultiplier,
-    }
-  }
-
-  setAltText(altText: string) {
-    this.altText = altText.trim()
-  }
-
-  // Only compress prior to upload
-  async compress() {
-    // do nothing
-  }
-
-  // Mobile
-  async crop() {
-    // do nothing
-  }
-
-  // Web manipulation
-  async manipulate(
-    _attributes: {
-      crop?: ActionCrop['crop']
-    } & ImageManipulationAttributes,
-  ) {
-    // do nothing
-  }
-
-  resetCropped() {
-    this.manipulate({})
-  }
-
-  previous() {
-    this.cropped = this.prev
-    this.attributes = this.prevAttributes
-  }
-}
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
deleted file mode 100644
index 55f636491..000000000
--- a/src/state/models/media/image.ts
+++ /dev/null
@@ -1,310 +0,0 @@
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import * as ImageManipulator from 'expo-image-manipulator'
-import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
-import {makeAutoObservable, runInAction} from 'mobx'
-import {Position} from 'react-avatar-editor'
-
-import {logger} from '#/logger'
-import {POST_IMG_MAX} from 'lib/constants'
-import {openCropper} from 'lib/media/picker'
-import {Dimensions} from 'lib/media/types'
-import {getDataUriSize} from 'lib/media/util'
-import {isIOS} from 'platform/detection'
-
-export interface ImageManipulationAttributes {
-  aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
-  rotate?: number
-  scale?: number
-  position?: Position
-  flipHorizontal?: boolean
-  flipVertical?: boolean
-}
-
-export interface ImageInitOptions {
-  path: string
-  width: number
-  height: number
-  altText?: string
-}
-
-const MAX_IMAGE_SIZE_IN_BYTES = 976560
-
-export class ImageModel implements Omit<RNImage, 'size'> {
-  path: string
-  mime = 'image/jpeg'
-  width: number
-  height: number
-  altText = ''
-  cropped?: RNImage = undefined
-  compressed?: RNImage = undefined
-
-  // Web manipulation
-  prev?: RNImage
-  attributes: ImageManipulationAttributes = {
-    aspectRatio: 'None',
-    scale: 1,
-    flipHorizontal: false,
-    flipVertical: false,
-    rotate: 0,
-  }
-  prevAttributes: ImageManipulationAttributes = {}
-
-  constructor(image: ImageInitOptions) {
-    makeAutoObservable(this)
-
-    this.path = image.path
-    this.width = image.width
-    this.height = image.height
-    if (image.altText !== undefined) {
-      this.setAltText(image.altText)
-    }
-  }
-
-  setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
-    this.attributes.aspectRatio = aspectRatio
-  }
-
-  setRotate(degrees: number) {
-    this.attributes.rotate = degrees
-    this.manipulate({})
-  }
-
-  flipVertical() {
-    this.attributes.flipVertical = !this.attributes.flipVertical
-    this.manipulate({})
-  }
-
-  flipHorizontal() {
-    this.attributes.flipHorizontal = !this.attributes.flipHorizontal
-    this.manipulate({})
-  }
-
-  get ratioMultipliers() {
-    return {
-      '4:3': 4 / 3,
-      '1:1': 1,
-      '3:4': 3 / 4,
-      None: this.width / this.height,
-    }
-  }
-
-  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]
-
-    if (ratioMultiplier === 1) {
-      return {
-        height: maxSide,
-        width: maxSide,
-      }
-    }
-
-    if (ratioMultiplier < 1) {
-      return {
-        width: maxSide * ratioMultiplier,
-        height: maxSide,
-      }
-    }
-
-    return {
-      width: maxSide,
-      height: maxSide / ratioMultiplier,
-    }
-  }
-
-  setAltText(altText: string) {
-    this.altText = altText.trim()
-  }
-
-  // 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 {
-      // NOTE
-      // on ios, react-native-image-crop-picker gives really bad quality
-      // without specifying width and height. on android, however, the
-      // crop stretches incorrectly if you do specify it. these are
-      // both separate bugs in the library. we deal with that by
-      // providing width & height for ios only
-      // -prf
-      const {width, height} = this.getUploadDimensions({
-        width: this.width,
-        height: this.height,
-      })
-
-      const cropped = await openCropper({
-        mediaType: 'photo',
-        path: this.path,
-        freeStyleCropEnabled: true,
-        ...(isIOS ? {width, height} : {}),
-      })
-
-      runInAction(() => {
-        this.cropped = cropped
-      })
-    } catch (err) {
-      logger.error('Failed to crop photo', {message: err})
-    }
-  }
-
-  // Web manipulation
-  async manipulate(
-    attributes: {
-      crop?: ActionCrop['crop']
-    } & ImageManipulationAttributes,
-  ) {
-    let uploadWidth: number | undefined
-    let uploadHeight: number | undefined
-
-    const {aspectRatio, crop, position, scale} = attributes
-    const modifiers = []
-
-    if (this.attributes.flipHorizontal) {
-      modifiers.push({flip: FlipType.Horizontal})
-    }
-
-    if (this.attributes.flipVertical) {
-      modifiers.push({flip: FlipType.Vertical})
-    }
-
-    if (this.attributes.rotate !== undefined) {
-      modifiers.push({rotate: this.attributes.rotate})
-    }
-
-    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: 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) {
-      this.attributes.scale = scale
-    }
-
-    if (position !== undefined) {
-      this.attributes.position = position
-    }
-
-    if (aspectRatio !== undefined) {
-      this.attributes.aspectRatio = aspectRatio
-    }
-
-    const ratioMultiplier =
-      this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
-
-    const result = await ImageManipulator.manipulateAsync(
-      this.path,
-      [
-        ...modifiers,
-        {
-          resize:
-            ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight},
-        },
-      ],
-      {
-        base64: true,
-        format: SaveFormat.JPEG,
-      },
-    )
-
-    runInAction(() => {
-      this.cropped = {
-        mime: 'image/jpeg',
-        path: result.uri,
-        size:
-          result.base64 !== undefined
-            ? getDataUriSize(result.base64)
-            : MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails
-        ...result,
-      }
-    })
-  }
-
-  resetCropped() {
-    this.manipulate({})
-  }
-
-  previous() {
-    this.cropped = this.prev
-    this.attributes = this.prevAttributes
-  }
-}
diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx
index 6755ec9a6..8e12386bd 100644
--- a/src/state/shell/composer/index.tsx
+++ b/src/state/shell/composer/index.tsx
@@ -9,6 +9,7 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {purgeTemporaryImageFiles} from '#/state/gallery'
 import * as Toast from '#/view/com/util/Toast'
 
 export interface ComposerOptsPostRef {
@@ -77,7 +78,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 
   const closeComposer = useNonReactiveCallback(() => {
     let wasOpen = !!state
-    setState(undefined)
+    if (wasOpen) {
+      setState(undefined)
+      purgeTemporaryImageFiles()
+    }
+
     return wasOpen
   })
 
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index dfdfb3ebd..3b7cf1385 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -44,7 +44,6 @@ import {RichText} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {observer} from 'mobx-react-lite'
 
 import {useAnalytics} from '#/lib/analytics/analytics'
 import * as apilib from '#/lib/api/index'
@@ -68,9 +67,9 @@ import {logger} from '#/logger'
 import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
 import {useDialogStateControlContext} from '#/state/dialogs'
 import {emitPostCreated} from '#/state/events'
+import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery'
 import {useModalControls} from '#/state/modals'
 import {useModals} from '#/state/modals'
-import {GalleryModel} from '#/state/models/media/gallery'
 import {useRequireAltTextEnabled} from '#/state/preferences'
 import {
   toPostLanguages,
@@ -122,12 +121,14 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import * as Prompt from '#/components/Prompt'
 import {Text as NewText} from '#/components/Typography'
 
+const MAX_IMAGES = 4
+
 type CancelRef = {
   onPressCancel: () => void
 }
 
 type Props = ComposerOpts
-export const ComposePost = observer(function ComposePost({
+export const ComposePost = ({
   replyTo,
   onPost,
   quote: initQuote,
@@ -139,7 +140,7 @@ export const ComposePost = observer(function ComposePost({
   cancelRef,
 }: Props & {
   cancelRef?: React.RefObject<CancelRef>
-}) {
+}) => {
   const {currentAccount} = useSession()
   const agent = useAgent()
   const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -212,9 +213,8 @@ export const ComposePost = observer(function ComposePost({
     )
   const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
 
-  const gallery = useMemo(
-    () => new GalleryModel(initImageUris),
-    [initImageUris],
+  const [images, setImages] = useState<ComposerImage[]>(() =>
+    createInitialImages(initImageUris),
   )
   const onClose = useCallback(() => {
     closeComposer()
@@ -233,7 +233,7 @@ export const ComposePost = observer(function ComposePost({
   const onPressCancel = useCallback(() => {
     if (
       graphemeLength > 0 ||
-      !gallery.isEmpty ||
+      images.length !== 0 ||
       extGif ||
       videoUploadState.status !== 'idle'
     ) {
@@ -246,7 +246,7 @@ export const ComposePost = observer(function ComposePost({
   }, [
     extGif,
     graphemeLength,
-    gallery.isEmpty,
+    images.length,
     closeAllDialogs,
     discardPromptControl,
     onClose,
@@ -299,22 +299,31 @@ export const ComposePost = observer(function ComposePost({
     [extLink, setExtLink],
   )
 
+  const onImageAdd = useCallback(
+    (next: ComposerImage[]) => {
+      setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length)))
+    },
+    [setImages],
+  )
+
   const onPhotoPasted = useCallback(
     async (uri: string) => {
       track('Composer:PastedPhotos')
       if (uri.startsWith('data:video/')) {
         selectVideo({uri, type: 'video', height: 0, width: 0})
       } else {
-        await gallery.paste(uri)
+        const res = await pasteImage(uri)
+        onImageAdd([res])
       }
     },
-    [gallery, track, selectVideo],
+    [track, selectVideo, onImageAdd],
   )
 
   const isAltTextRequiredAndMissing = useMemo(() => {
     if (!requireAltTextEnabled) return false
 
-    if (gallery.needsAltText) return true
+    if (images.some(img => img.alt === '')) return true
+
     if (extGif) {
       if (!extLink?.meta?.description) return true
 
@@ -322,7 +331,7 @@ export const ComposePost = observer(function ComposePost({
       if (!parsedAlt.isPreferred) return true
     }
     return false
-  }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
+  }, [images, extLink, extGif, requireAltTextEnabled])
 
   const onPressPublish = React.useCallback(
     async (finishedUploading?: boolean) => {
@@ -347,7 +356,7 @@ export const ComposePost = observer(function ComposePost({
 
       if (
         richtext.text.trim().length === 0 &&
-        gallery.isEmpty &&
+        images.length === 0 &&
         !extLink &&
         !quote &&
         videoUploadState.status === 'idle'
@@ -368,7 +377,7 @@ export const ComposePost = observer(function ComposePost({
           await apilib.post(agent, {
             rawText: richtext.text,
             replyTo: replyTo?.uri,
-            images: gallery.images,
+            images,
             quote,
             extLink,
             labels,
@@ -405,7 +414,7 @@ export const ComposePost = observer(function ComposePost({
       } catch (e: any) {
         logger.error(e, {
           message: `Composer: create post failed`,
-          hasImages: gallery.size > 0,
+          hasImages: images.length > 0,
         })
 
         if (extLink) {
@@ -427,7 +436,7 @@ export const ComposePost = observer(function ComposePost({
       } finally {
         if (postUri) {
           logEvent('post:create', {
-            imageCount: gallery.size,
+            imageCount: images.length,
             isReply: replyTo != null,
             hasLink: extLink != null,
             hasQuote: quote != null,
@@ -436,7 +445,7 @@ export const ComposePost = observer(function ComposePost({
           })
         }
         track('Create Post', {
-          imageCount: gallery.size,
+          imageCount: images.length,
         })
         if (replyTo && replyTo.uri) track('Post:Reply')
       }
@@ -472,9 +481,7 @@ export const ComposePost = observer(function ComposePost({
       agent,
       captions,
       extLink,
-      gallery.images,
-      gallery.isEmpty,
-      gallery.size,
+      images,
       graphemeLength,
       isAltTextRequiredAndMissing,
       isProcessing,
@@ -516,12 +523,12 @@ export const ComposePost = observer(function ComposePost({
     : _(msg`What's up?`)
 
   const canSelectImages =
-    gallery.size < 4 &&
+    images.length < MAX_IMAGES &&
     !extLink &&
     videoUploadState.status === 'idle' &&
     !videoUploadState.video
   const hasMedia =
-    gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
+    images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
 
   const onEmojiButtonPress = useCallback(() => {
     openEmojiPicker?.(textInput.current?.getCursorPosition())
@@ -716,8 +723,8 @@ export const ComposePost = observer(function ComposePost({
             />
           </View>
 
-          <Gallery gallery={gallery} />
-          {gallery.isEmpty && extLink && (
+          <Gallery images={images} onChange={setImages} />
+          {images.length === 0 && extLink && (
             <View style={a.relative}>
               <ExternalEmbed
                 link={extLink}
@@ -801,13 +808,17 @@ export const ComposePost = observer(function ComposePost({
             <VideoUploadToolbar state={videoUploadState} />
           ) : (
             <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
-              <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
+              <SelectPhotoBtn
+                size={images.length}
+                disabled={!canSelectImages}
+                onAdd={onImageAdd}
+              />
               <SelectVideoBtn
                 onSelectVideo={selectVideo}
                 disabled={!canSelectImages}
                 setError={setError}
               />
-              <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
+              <OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
               <SelectGifBtn
                 onClose={focusTextInput}
                 onSelectGif={onSelectGif}
@@ -842,7 +853,7 @@ export const ComposePost = observer(function ComposePost({
       />
     </KeyboardAvoidingView>
   )
-})
+}
 
 export function useComposerCancelRef() {
   return useRef<CancelRef>(null)
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 4801ca0ab..f61d410df 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -26,7 +26,7 @@ export const ExternalEmbed = ({
         title: link.meta?.title ?? link.uri,
         uri: link.uri,
         description: link.meta?.description ?? '',
-        thumb: link.localThumb?.path,
+        thumb: link.localThumb?.source.path,
       },
     [link],
   )
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index a37452604..a05607c76 100644
--- a/src/view/com/composer/GifAltText.tsx
+++ b/src/view/com/composer/GifAltText.tsx
@@ -43,7 +43,7 @@ export function GifAltText({
         title: linkProp.meta?.title ?? linkProp.uri,
         uri: linkProp.uri,
         description: linkProp.meta?.description ?? '',
-        thumb: linkProp.localThumb?.path,
+        thumb: linkProp.localThumb?.source.path,
       },
       params: parseEmbedPlayerFromUrl(linkProp.uri),
     }
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 422a4dd93..775413e81 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -1,29 +1,36 @@
-import React, {useState} from 'react'
-import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import React from 'react'
+import {
+  ImageStyle,
+  Keyboard,
+  LayoutChangeEvent,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
 import {Image} from 'expo-image'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {observer} from 'mobx-react-lite'
 
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {Dimensions} from '#/lib/media/types'
 import {colors, s} from '#/lib/styles'
 import {isNative} from '#/platform/detection'
+import {ComposerImage, cropImage} from '#/state/gallery'
 import {useModalControls} from '#/state/modals'
-import {GalleryModel} from '#/state/models/media/gallery'
 import {Text} from '#/view/com/util/text/Text'
 import {useTheme} from '#/alf'
 
 const IMAGE_GAP = 8
 
 interface GalleryProps {
-  gallery: GalleryModel
+  images: ComposerImage[]
+  onChange: (next: ComposerImage[]) => void
 }
 
-export const Gallery = (props: GalleryProps) => {
-  const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
+export let Gallery = (props: GalleryProps): React.ReactNode => {
+  const [containerInfo, setContainerInfo] = React.useState<Dimensions>()
 
   const onLayout = (evt: LayoutChangeEvent) => {
     const {width, height} = evt.nativeEvent.layout
@@ -41,177 +48,190 @@ export const Gallery = (props: GalleryProps) => {
     </View>
   )
 }
+Gallery = React.memo(Gallery)
 
 interface GalleryInnerProps extends GalleryProps {
   containerInfo: Dimensions
 }
 
-const GalleryInner = observer(function GalleryImpl({
-  gallery,
-  containerInfo,
-}: GalleryInnerProps) {
-  const {_} = useLingui()
+const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => {
   const {isMobile} = useWebMediaQueries()
-  const {openModal} = useModalControls()
-  const t = useTheme()
 
-  let side: number
+  const {altTextControlStyle, imageControlsStyle, imageStyle} =
+    React.useMemo(() => {
+      const side =
+        images.length === 1
+          ? 250
+          : (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
+            images.length
 
-  if (gallery.size === 1) {
-    side = 250
-  } else {
-    side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size
-  }
+      const isOverflow = isMobile && images.length > 2
 
-  const imageStyle = {
-    height: side,
-    width: side,
-  }
-
-  const isOverflow = isMobile && gallery.size > 2
-
-  const altTextControlStyle = isOverflow
-    ? {
-        left: 4,
-        bottom: 4,
-      }
-    : !isMobile && gallery.size < 3
-    ? {
-        left: 8,
-        top: 8,
-      }
-    : {
-        left: 4,
-        top: 4,
+      return {
+        altTextControlStyle: isOverflow
+          ? {left: 4, bottom: 4}
+          : !isMobile && images.length < 3
+          ? {left: 8, top: 8}
+          : {left: 4, top: 4},
+        imageControlsStyle: {
+          display: 'flex' as const,
+          flexDirection: 'row' as const,
+          position: 'absolute' as const,
+          ...(isOverflow
+            ? {top: 4, right: 4, gap: 4}
+            : !isMobile && images.length < 3
+            ? {top: 8, right: 8, gap: 8}
+            : {top: 4, right: 4, gap: 4}),
+          zIndex: 1,
+        },
+        imageStyle: {
+          height: side,
+          width: side,
+        },
       }
+    }, [images.length, containerInfo, isMobile])
 
-  const imageControlsStyle = {
-    display: 'flex' as const,
-    flexDirection: 'row' as const,
-    position: 'absolute' as const,
-    ...(isOverflow
-      ? {
-          top: 4,
-          right: 4,
-          gap: 4,
-        }
-      : !isMobile && gallery.size < 3
-      ? {
-          top: 8,
-          right: 8,
-          gap: 8,
-        }
-      : {
-          top: 4,
-          right: 4,
-          gap: 4,
-        }),
-    zIndex: 1,
-  }
-
-  return !gallery.isEmpty ? (
+  return images.length !== 0 ? (
     <>
       <View testID="selectedPhotosView" style={styles.gallery}>
-        {gallery.images.map(image => (
-          <View key={`selected-image-${image.path}`} style={[imageStyle]}>
-            <TouchableOpacity
-              testID="altTextButton"
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Add alt text`)}
-              accessibilityHint=""
-              onPress={() => {
-                Keyboard.dismiss()
-                openModal({
-                  name: 'alt-text-image',
-                  image,
-                })
-              }}
-              style={[styles.altTextControl, altTextControlStyle]}>
-              {image.altText.length > 0 ? (
-                <FontAwesomeIcon
-                  icon="check"
-                  size={10}
-                  style={{color: t.palette.white}}
-                />
-              ) : (
-                <FontAwesomeIcon
-                  icon="plus"
-                  size={10}
-                  style={{color: t.palette.white}}
-                />
-              )}
-              <Text style={styles.altTextControlLabel} accessible={false}>
-                <Trans>ALT</Trans>
-              </Text>
-            </TouchableOpacity>
-            <View style={imageControlsStyle}>
-              <TouchableOpacity
-                testID="editPhotoButton"
-                accessibilityRole="button"
-                accessibilityLabel={_(msg`Edit image`)}
-                accessibilityHint=""
-                onPress={() => {
-                  if (isNative) {
-                    gallery.crop(image)
-                  } else {
-                    openModal({
-                      name: 'edit-image',
-                      image,
-                      gallery,
-                    })
-                  }
-                }}
-                style={styles.imageControl}>
-                <FontAwesomeIcon
-                  icon="pen"
-                  size={12}
-                  style={{color: colors.white}}
-                />
-              </TouchableOpacity>
-              <TouchableOpacity
-                testID="removePhotoButton"
-                accessibilityRole="button"
-                accessibilityLabel={_(msg`Remove image`)}
-                accessibilityHint=""
-                onPress={() => gallery.remove(image)}
-                style={styles.imageControl}>
-                <FontAwesomeIcon
-                  icon="xmark"
-                  size={16}
-                  style={{color: colors.white}}
-                />
-              </TouchableOpacity>
-            </View>
-            <TouchableOpacity
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Add alt text`)}
-              accessibilityHint=""
-              onPress={() => {
-                Keyboard.dismiss()
-                openModal({
-                  name: 'alt-text-image',
-                  image,
-                })
+        {images.map((image, index) => {
+          return (
+            <GalleryItem
+              key={image.source.id}
+              image={image}
+              altTextControlStyle={altTextControlStyle}
+              imageControlsStyle={imageControlsStyle}
+              imageStyle={imageStyle}
+              onChange={next => {
+                onChange(
+                  images.map(i => (i.source === image.source ? next : i)),
+                )
               }}
-              style={styles.altTextHiddenRegion}
-            />
+              onRemove={() => {
+                const next = images.slice()
+                next.splice(index, 1)
 
-            <Image
-              testID="selectedPhotoImage"
-              style={[styles.image, imageStyle] as ImageStyle}
-              source={{
-                uri: image.cropped?.path ?? image.path,
+                onChange(next)
               }}
-              accessible={true}
-              accessibilityIgnoresInvertColors
             />
-          </View>
-        ))}
+          )
+        })}
       </View>
       <AltTextReminder />
     </>
   ) : null
-})
+}
+
+type GalleryItemProps = {
+  image: ComposerImage
+  altTextControlStyle?: ViewStyle
+  imageControlsStyle?: ViewStyle
+  imageStyle?: ViewStyle
+  onChange: (next: ComposerImage) => void
+  onRemove: () => void
+}
+
+const GalleryItem = ({
+  image,
+  altTextControlStyle,
+  imageControlsStyle,
+  imageStyle,
+  onChange,
+  onRemove,
+}: GalleryItemProps): React.ReactNode => {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {openModal} = useModalControls()
+
+  const onImageEdit = () => {
+    if (isNative) {
+      cropImage(image).then(next => {
+        onChange(next)
+      })
+    }
+  }
+
+  const onAltTextEdit = () => {
+    Keyboard.dismiss()
+    openModal({name: 'alt-text-image', image, onChange})
+  }
+
+  return (
+    <View style={imageStyle}>
+      <TouchableOpacity
+        testID="altTextButton"
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Add alt text`)}
+        accessibilityHint=""
+        onPress={onAltTextEdit}
+        style={[styles.altTextControl, altTextControlStyle]}>
+        {image.alt.length !== 0 ? (
+          <FontAwesomeIcon
+            icon="check"
+            size={10}
+            style={{color: t.palette.white}}
+          />
+        ) : (
+          <FontAwesomeIcon
+            icon="plus"
+            size={10}
+            style={{color: t.palette.white}}
+          />
+        )}
+        <Text style={styles.altTextControlLabel} accessible={false}>
+          <Trans>ALT</Trans>
+        </Text>
+      </TouchableOpacity>
+      <View style={imageControlsStyle}>
+        {isNative && (
+          <TouchableOpacity
+            testID="editPhotoButton"
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Edit image`)}
+            accessibilityHint=""
+            onPress={onImageEdit}
+            style={styles.imageControl}>
+            <FontAwesomeIcon
+              icon="pen"
+              size={12}
+              style={{color: colors.white}}
+            />
+          </TouchableOpacity>
+        )}
+        <TouchableOpacity
+          testID="removePhotoButton"
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Remove image`)}
+          accessibilityHint=""
+          onPress={onRemove}
+          style={styles.imageControl}>
+          <FontAwesomeIcon
+            icon="xmark"
+            size={16}
+            style={{color: colors.white}}
+          />
+        </TouchableOpacity>
+      </View>
+      <TouchableOpacity
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Add alt text`)}
+        accessibilityHint=""
+        onPress={onAltTextEdit}
+        style={styles.altTextHiddenRegion}
+      />
+
+      <Image
+        testID="selectedPhotoImage"
+        style={[styles.image, imageStyle] as ImageStyle}
+        source={{
+          uri: (image.transformed ?? image.source).path,
+        }}
+        accessible={true}
+        accessibilityIgnoresInvertColors
+      />
+    </View>
+  )
+}
 
 export function AltTextReminder() {
   const t = useTheme()
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index f1f984103..2183ca790 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -9,17 +9,17 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions'
 import {openCamera} from '#/lib/media/picker'
 import {logger} from '#/logger'
 import {isMobileWeb, isNative} from '#/platform/detection'
-import {GalleryModel} from '#/state/models/media/gallery'
+import {ComposerImage, createComposerImage} from '#/state/gallery'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
 import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
 
 type Props = {
-  gallery: GalleryModel
   disabled?: boolean
+  onAdd: (next: ComposerImage[]) => void
 }
 
-export function OpenCameraBtn({gallery, disabled}: Props) {
+export function OpenCameraBtn({disabled, onAdd}: Props) {
   const {track} = useAnalytics()
   const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
@@ -48,13 +48,16 @@ export function OpenCameraBtn({gallery, disabled}: Props) {
       if (mediaPermissionRes) {
         await MediaLibrary.createAssetAsync(img.path)
       }
-      gallery.add(img)
+
+      const res = await createComposerImage(img)
+
+      onAdd([res])
     } catch (err: any) {
       // ignore
       logger.warn('Error using camera', {error: err})
     }
   }, [
-    gallery,
+    onAdd,
     track,
     requestCameraAccessIfNeeded,
     mediaPermissionRes,
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index 747653fc8..95d2df022 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -5,18 +5,20 @@ import {useLingui} from '@lingui/react'
 
 import {useAnalytics} from '#/lib/analytics/analytics'
 import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
+import {openPicker} from '#/lib/media/picker'
 import {isNative} from '#/platform/detection'
-import {GalleryModel} from '#/state/models/media/gallery'
+import {ComposerImage, createComposerImage} from '#/state/gallery'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
 import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
 
 type Props = {
-  gallery: GalleryModel
+  size: number
   disabled?: boolean
+  onAdd: (next: ComposerImage[]) => void
 }
 
-export function SelectPhotoBtn({gallery, disabled}: Props) {
+export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
   const {track} = useAnalytics()
   const {_} = useLingui()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
@@ -29,8 +31,17 @@ export function SelectPhotoBtn({gallery, disabled}: Props) {
       return
     }
 
-    gallery.pick()
-  }, [track, requestPhotoAccessIfNeeded, gallery])
+    const images = await openPicker({
+      selectionLimit: 4 - size,
+      allowsMultipleSelection: true,
+    })
+
+    const results = await Promise.all(
+      images.map(img => createComposerImage(img)),
+    )
+
+    onAdd(results)
+  }, [track, requestPhotoAccessIfNeeded, size, onAdd])
 
   return (
     <Button
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index 317514437..1a36b5034 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -3,6 +3,7 @@ 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'
@@ -26,7 +27,6 @@ import {
   isBskyStartUrl,
   isShortLink,
 } from 'lib/strings/url-helpers'
-import {ImageModel} from 'state/models/media/image'
 import {ComposerOpts} from 'state/shell/composer'
 
 export function useExternalLinkFetch({
@@ -161,14 +161,15 @@ export function useExternalLinkFetch({
         timeout: 15e3,
       })
         .catch(() => undefined)
-        .then(localThumb => {
+        .then(thumb => (thumb ? createComposerImage(thumb) : undefined))
+        .then(thumb => {
           if (aborted) {
             return
           }
           setExtLink({
             ...extLink,
             isLoading: false, // done
-            localThumb: localThumb ? new ImageModel(localThumb) : undefined,
+            localThumb: thumb,
           })
         })
       return cleanup
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
index ba489cde7..c711f73a5 100644
--- a/src/view/com/modals/AltImage.tsx
+++ b/src/view/com/modals/AltImage.tsx
@@ -13,6 +13,7 @@ import {LinearGradient} from 'expo-linear-gradient'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {ComposerImage} from '#/state/gallery'
 import {useModalControls} from '#/state/modals'
 import {MAX_ALT_TEXT} from 'lib/constants'
 import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
@@ -21,21 +22,21 @@ import {enforceLen} from 'lib/strings/helpers'
 import {gradients, s} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
 import {isAndroid, isWeb} from 'platform/detection'
-import {ImageModel} from 'state/models/media/image'
 import {Text} from '../util/text/Text'
 import {ScrollView, TextInput} from './util'
 
 export const snapPoints = ['100%']
 
 interface Props {
-  image: ImageModel
+  image: ComposerImage
+  onChange: (next: ComposerImage) => void
 }
 
-export function Component({image}: Props) {
+export function Component({image, onChange}: Props) {
   const pal = usePalette('default')
   const theme = useTheme()
   const {_} = useLingui()
-  const [altText, setAltText] = useState(image.altText)
+  const [altText, setAltText] = useState(image.alt)
   const windim = useWindowDimensions()
   const {closeModal} = useModalControls()
   const inputRef = React.useRef<RNTextInput>(null)
@@ -60,7 +61,8 @@ export function Component({image}: Props) {
 
   const imageStyles = useMemo<ImageStyle>(() => {
     const maxWidth = isWeb ? 450 : windim.width
-    if (image.height > image.width) {
+    const media = image.transformed ?? image.source
+    if (media.height > media.width) {
       return {
         resizeMode: 'contain',
         width: '100%',
@@ -70,7 +72,7 @@ export function Component({image}: Props) {
     }
     return {
       width: '100%',
-      height: (maxWidth / image.width) * image.height,
+      height: (maxWidth / media.width) * media.height,
       borderRadius: 8,
     }
   }, [image, windim])
@@ -79,15 +81,18 @@ export function Component({image}: Props) {
     (v: string) => {
       v = enforceLen(v, MAX_ALT_TEXT)
       setAltText(v)
-      image.setAltText(v)
     },
-    [setAltText, image],
+    [setAltText],
   )
 
   const onPressSave = useCallback(() => {
-    image.setAltText(altText)
+    onChange({
+      ...image,
+      alt: altText,
+    })
+
     closeModal()
-  }, [closeModal, image, altText])
+  }, [closeModal, image, altText, onChange])
 
   return (
     <ScrollView
@@ -101,9 +106,7 @@ export function Component({image}: Props) {
           <Image
             testID="selectedPhotoImage"
             style={imageStyles}
-            source={{
-              uri: image.cropped?.path ?? image.path,
-            }}
+            source={{uri: (image.transformed ?? image.source).path}}
             contentFit="contain"
             accessible={true}
             accessibilityIgnoresInvertColors
diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx
deleted file mode 100644
index c921984d4..000000000
--- a/src/view/com/modals/EditImage.tsx
+++ /dev/null
@@ -1,380 +0,0 @@
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
-import {Pressable, StyleSheet, View} from 'react-native'
-import {useWindowDimensions} from 'react-native'
-import {LinearGradient} from 'expo-linear-gradient'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {Slider} from '@miblanchard/react-native-slider'
-import {observer} from 'mobx-react-lite'
-import ImageEditor, {Position} from 'react-avatar-editor'
-
-import {MAX_ALT_TEXT} from '#/lib/constants'
-import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {enforceLen} from '#/lib/strings/helpers'
-import {gradients, s} from '#/lib/styles'
-import {useTheme} from '#/lib/ThemeContext'
-import {getKeys} from '#/lib/type-assertions'
-import {useModalControls} from '#/state/modals'
-import {GalleryModel} from '#/state/models/media/gallery'
-import {ImageModel} from '#/state/models/media/image'
-import {atoms as a} from '#/alf'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {
-  AspectRatio11_Stroke2_Corner0_Rounded as A11,
-  AspectRatio34_Stroke2_Corner0_Rounded as A34,
-  AspectRatio43_Stroke2_Corner0_Rounded as A43,
-} from '#/components/icons/AspectRatio'
-import {CircleBanSign_Stroke2_Corner0_Rounded as Ban} from '#/components/icons/CircleBanSign'
-import {
-  FlipHorizontal_Stroke2_Corner0_Rounded as FlipHorizontal,
-  FlipVertical_Stroke2_Corner0_Rounded as FlipVertical,
-} from '#/components/icons/FlipImage'
-import {Text} from '../util/text/Text'
-import {TextInput} from './util'
-
-export const snapPoints = ['80%']
-
-const RATIOS = {
-  '4:3': {
-    icon: A43,
-  },
-  '1:1': {
-    icon: A11,
-  },
-  '3:4': {
-    icon: A34,
-  },
-  None: {
-    icon: Ban,
-  },
-} as const
-
-type AspectRatio = keyof typeof RATIOS
-
-interface Props {
-  image: ImageModel
-  gallery: GalleryModel
-}
-
-export const Component = observer(function EditImageImpl({
-  image,
-  gallery,
-}: Props) {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const {_} = useLingui()
-  const windowDimensions = useWindowDimensions()
-  const {isMobile} = useWebMediaQueries()
-  const {closeModal} = useModalControls()
-
-  const {
-    aspectRatio,
-    // rotate = 0
-  } = image.attributes
-
-  const editorRef = useRef<ImageEditor>(null)
-  const [scale, setScale] = useState<number>(image.attributes.scale ?? 1)
-  const [position, setPosition] = useState<Position | undefined>(
-    image.attributes.position,
-  )
-  const [altText, setAltText] = useState(image?.altText ?? '')
-
-  const onFlipHorizontal = useCallback(() => {
-    image.flipHorizontal()
-  }, [image])
-
-  const onFlipVertical = useCallback(() => {
-    image.flipVertical()
-  }, [image])
-
-  // const onSetRotate = useCallback(
-  //   (direction: 'left' | 'right') => {
-  //     const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360
-  //     image.setRotate(rotation)
-  //   },
-  //   [rotate, image],
-  // )
-
-  const onSetRatio = useCallback(
-    (ratio: AspectRatio) => {
-      image.setRatio(ratio)
-    },
-    [image],
-  )
-
-  const adjustments = useMemo(
-    () => [
-      // {
-      //   name: 'rotate-left' as const,
-      //   label: 'Rotate left',
-      //   onPress: () => {
-      //     onSetRotate('left')
-      //   },
-      // },
-      // {
-      //   name: 'rotate-right' as const,
-      //   label: 'Rotate right',
-      //   onPress: () => {
-      //     onSetRotate('right')
-      //   },
-      // },
-      {
-        icon: FlipHorizontal,
-        label: _(msg`Flip horizontal`),
-        onPress: onFlipHorizontal,
-      },
-      {
-        icon: FlipVertical,
-        label: _(msg`Flip vertically`),
-        onPress: onFlipVertical,
-      },
-    ],
-    [onFlipHorizontal, onFlipVertical, _],
-  )
-
-  useEffect(() => {
-    image.prev = image.cropped
-    image.prevAttributes = image.attributes
-    image.resetCropped()
-  }, [image])
-
-  const onCloseModal = useCallback(() => {
-    closeModal()
-  }, [closeModal])
-
-  const onPressCancel = useCallback(async () => {
-    await gallery.previous(image)
-    onCloseModal()
-  }, [onCloseModal, gallery, image])
-
-  const onPressSave = useCallback(async () => {
-    image.setAltText(altText)
-
-    const crop = editorRef.current?.getCroppingRect()
-
-    await image.manipulate({
-      ...(crop !== undefined
-        ? {
-            crop: {
-              originX: crop.x,
-              originY: crop.y,
-              width: crop.width,
-              height: crop.height,
-            },
-            ...(scale !== 1 ? {scale} : {}),
-            ...(position !== undefined ? {position} : {}),
-          }
-        : {}),
-    })
-
-    image.prev = image.cropped
-    image.prevAttributes = image.attributes
-    onCloseModal()
-  }, [altText, image, position, scale, onCloseModal])
-
-  if (image.cropped === undefined) {
-    return null
-  }
-
-  const computedWidth =
-    windowDimensions.width > 500 ? 410 : windowDimensions.width - 80
-  const sideLength = isMobile ? computedWidth : 300
-
-  const dimensions = image.getResizedDimensions(aspectRatio, sideLength)
-  const imgContainerStyles = {width: sideLength, height: sideLength}
-
-  const imgControlStyles = {
-    alignItems: 'center' as const,
-    flexDirection: isMobile ? ('column' as const) : ('row' as const),
-    gap: isMobile ? 0 : 5,
-  }
-
-  return (
-    <View
-      testID="editImageModal"
-      style={[
-        pal.view,
-        styles.container,
-        s.flex1,
-        {
-          paddingHorizontal: isMobile ? 16 : undefined,
-        },
-      ]}>
-      <Text style={[styles.title, pal.text]}>
-        <Trans>Edit image</Trans>
-      </Text>
-      <View style={[styles.gap18, s.flexRow]}>
-        <View>
-          <View
-            style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}>
-            <ImageEditor
-              ref={editorRef}
-              style={styles.imgEditor}
-              image={image.cropped.path}
-              scale={scale}
-              border={0}
-              position={position}
-              onPositionChange={setPosition}
-              {...dimensions}
-            />
-          </View>
-          <Slider
-            value={scale}
-            onValueChange={(v: number | number[]) =>
-              setScale(Array.isArray(v) ? v[0] : v)
-            }
-            minimumValue={1}
-            maximumValue={3}
-          />
-        </View>
-        <View style={[a.gap_sm]}>
-          {!isMobile ? (
-            <Text type="sm-bold" style={pal.text}>
-              <Trans>Ratios</Trans>
-            </Text>
-          ) : null}
-          <View style={imgControlStyles}>
-            {getKeys(RATIOS).map(ratio => {
-              const {icon} = RATIOS[ratio]
-              const isSelected = aspectRatio === ratio
-
-              return (
-                <Button
-                  key={ratio}
-                  label={ratio}
-                  size="large"
-                  shape="square"
-                  variant="outline"
-                  color={isSelected ? 'primary' : 'secondary'}
-                  onPress={() => {
-                    onSetRatio(ratio)
-                  }}>
-                  <View style={[a.align_center, a.gap_2xs]}>
-                    <ButtonIcon icon={icon} />
-                    <ButtonText style={[a.text_xs]}>{ratio}</ButtonText>
-                  </View>
-                </Button>
-              )
-            })}
-          </View>
-          {!isMobile ? (
-            <Text type="sm-bold" style={[pal.text, styles.subsection]}>
-              <Trans>Transformations</Trans>
-            </Text>
-          ) : null}
-          <View style={imgControlStyles}>
-            {adjustments.map(({label, icon, onPress}) => (
-              <Button
-                key={label}
-                label={label}
-                size="large"
-                shape="square"
-                variant="outline"
-                color="secondary"
-                onPress={onPress}>
-                <ButtonIcon icon={icon} />
-              </Button>
-            ))}
-          </View>
-        </View>
-      </View>
-      <View style={[styles.gap18, styles.bottomSection, pal.border]}>
-        <Text type="sm-bold" style={pal.text} nativeID="alt-text">
-          <Trans>Accessibility</Trans>
-        </Text>
-        <TextInput
-          testID="altTextImageInput"
-          style={[
-            styles.textArea,
-            pal.border,
-            pal.text,
-            {
-              maxHeight: isMobile ? 50 : undefined,
-            },
-          ]}
-          keyboardAppearance={theme.colorScheme}
-          multiline
-          value={altText}
-          onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
-          accessibilityLabel={_(msg`Alt text`)}
-          accessibilityHint=""
-          accessibilityLabelledBy="alt-text"
-        />
-      </View>
-      <View style={styles.btns}>
-        <Pressable onPress={onPressCancel} accessibilityRole="button">
-          <Text type="xl" style={pal.link}>
-            <Trans>Cancel</Trans>
-          </Text>
-        </Pressable>
-        <Pressable onPress={onPressSave} accessibilityRole="button">
-          <LinearGradient
-            colors={[gradients.blueLight.start, gradients.blueLight.end]}
-            start={{x: 0, y: 0}}
-            end={{x: 1, y: 1}}
-            style={[styles.btn]}>
-            <Text type="xl-medium" style={s.white}>
-              <Trans context="action">Done</Trans>
-            </Text>
-          </LinearGradient>
-        </Pressable>
-      </View>
-    </View>
-  )
-})
-
-const styles = StyleSheet.create({
-  container: {
-    gap: 18,
-    height: '100%',
-    width: '100%',
-  },
-  subsection: {marginTop: 12},
-  gap18: {gap: 18},
-  title: {
-    fontWeight: '600',
-    fontSize: 24,
-  },
-  btns: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'space-between',
-  },
-  btn: {
-    borderRadius: 4,
-    paddingVertical: 8,
-    paddingHorizontal: 24,
-  },
-  imgEditor: {
-    maxWidth: '100%',
-  },
-  imgContainer: {
-    display: 'flex',
-    alignItems: 'center',
-    justifyContent: 'center',
-    borderWidth: 1,
-    borderStyle: 'solid',
-    marginBottom: 4,
-  },
-  flipVertical: {
-    transform: [{rotate: '90deg'}],
-  },
-  flipBtn: {
-    paddingHorizontal: 4,
-    paddingVertical: 8,
-  },
-  textArea: {
-    borderWidth: 1,
-    borderRadius: 6,
-    paddingTop: 10,
-    paddingHorizontal: 12,
-    fontSize: 16,
-    height: 100,
-    textAlignVertical: 'top',
-  },
-  bottomSection: {
-    borderTopWidth: 1,
-    paddingTop: 18,
-  },
-})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 3455e1cdf..fd881ebc4 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -9,7 +9,6 @@ import {FullWindowOverlay} from '#/components/FullWindowOverlay'
 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
 import * as AddAppPassword from './AddAppPasswords'
 import * as AltImageModal from './AltImage'
-import * as EditImageModal from './AltImage'
 import * as ChangeEmailModal from './ChangeEmail'
 import * as ChangeHandleModal from './ChangeHandle'
 import * as ChangePasswordModal from './ChangePassword'
@@ -78,9 +77,6 @@ export 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/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index c4bab6fb1..fe24695d2 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -15,7 +15,6 @@ import * as ChangePasswordModal from './ChangePassword'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as CropImageModal from './crop-image/CropImage.web'
 import * as DeleteAccountModal from './DeleteAccount'
-import * as EditImageModal from './EditImage'
 import * as EditProfileModal from './EditProfile'
 import * as InviteCodesModal from './InviteCodes'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
@@ -54,11 +53,7 @@ function Modal({modal}: {modal: ModalIface}) {
   }
 
   const onPressMask = () => {
-    if (
-      modal.name === 'crop-image' ||
-      modal.name === 'edit-image' ||
-      modal.name === 'alt-text-image'
-    ) {
+    if (modal.name === 'crop-image' || modal.name === 'alt-text-image') {
       return // dont close on mask presses during crop
     }
     closeModal()
@@ -95,8 +90,6 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <PostLanguagesSettingsModal.Component />
   } else if (modal.name === 'alt-text-image') {
     element = <AltTextImageModal.Component {...modal} />
-  } else if (modal.name === 'edit-image') {
-    element = <EditImageModal.Component {...modal} />
   } else if (modal.name === 'verify-email') {
     element = <VerifyEmailModal.Component {...modal} />
   } else if (modal.name === 'change-email') {
diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx
index 7d3780801..bbb837f1f 100644
--- a/src/view/shell/Composer.ios.tsx
+++ b/src/view/shell/Composer.ios.tsx
@@ -2,16 +2,13 @@ import React, {useLayoutEffect} from 'react'
 import {Modal, View} from 'react-native'
 import {StatusBar} from 'expo-status-bar'
 import * as SystemUI from 'expo-system-ui'
-import {observer} from 'mobx-react-lite'
 
 import {useComposerState} from '#/state/shell/composer'
 import {atoms as a, useTheme} from '#/alf'
 import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme'
 import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
 
-export const Composer = observer(function ComposerImpl({}: {
-  winHeight: number
-}) {
+export function Composer({}: {winHeight: number}) {
   const t = useTheme()
   const state = useComposerState()
   const ref = useComposerCancelRef()
@@ -42,7 +39,7 @@ export const Composer = observer(function ComposerImpl({}: {
       </View>
     </Modal>
   )
-})
+}
 
 function Providers({
   children,
diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx
index 1c97df9c3..049f35d35 100644
--- a/src/view/shell/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -1,17 +1,12 @@
 import React, {useEffect} from 'react'
 import {Animated, Easing, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useComposerState} from 'state/shell/composer'
+import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useComposerState} from '#/state/shell/composer'
 import {ComposePost} from '../com/composer/Composer'
 
-export const Composer = observer(function ComposerImpl({
-  winHeight,
-}: {
-  winHeight: number
-}) {
+export function Composer({winHeight}: {winHeight: number}) {
   const state = useComposerState()
   const pal = usePalette('default')
   const initInterp = useAnimatedValue(0)
@@ -62,7 +57,7 @@ export const Composer = observer(function ComposerImpl({
       />
     </Animated.View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   wrapper: {
diff --git a/yarn.lock b/yarn.lock
index 65b24915d..860b49dae 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -16760,21 +16760,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
   integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
 
-mobx-react-lite@^3.4.0:
-  version "3.4.3"
-  resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz#3a4c22c30bfaa8b1b2aa48d12b2ba811c0947ab7"
-  integrity sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg==
-
-mobx-utils@^6.0.6:
-  version "6.0.8"
-  resolved "https://registry.yarnpkg.com/mobx-utils/-/mobx-utils-6.0.8.tgz#843e222c7694050c2e42842682fd24a84fdb7024"
-  integrity sha512-fPNt0vJnHwbQx9MojJFEnJLfM3EMGTtpy4/qOOW6xueh1mPofMajrbYAUvByMYAvCJnpy1A5L0t+ZVB5niKO4g==
-
-mobx@^6.6.1:
-  version "6.10.0"
-  resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.10.0.tgz#3537680fe98d45232cc19cc8f76280bd8bb6b0b7"
-  integrity sha512-WMbVpCMFtolbB8swQ5E2YRrU+Yu8iLozCVx3CdGjbBKlP7dFiCSuiG06uea3JCFN5DnvtAX7+G5Bp82e2xu0ww==
-
 moo@^0.5.1:
   version "0.5.2"
   resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c"