about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-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
6 files changed, 308 insertions, 577 deletions
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
   })