about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/api/index.ts2
-rw-r--r--src/lib/constants.ts8
-rw-r--r--src/lib/media/manip.ts198
-rw-r--r--src/lib/media/manip.web.ts192
-rw-r--r--src/lib/media/picker.e2e.tsx106
-rw-r--r--src/lib/media/picker.tsx87
-rw-r--r--src/lib/media/picker.web.tsx69
-rw-r--r--src/lib/media/types.ts28
-rw-r--r--src/lib/media/util.ts40
-rw-r--r--src/state/models/cache/image-sizes.ts18
-rw-r--r--src/state/models/content/profile.ts6
-rw-r--r--src/state/models/media/gallery.ts85
-rw-r--r--src/state/models/media/image.ts85
-rw-r--r--src/state/models/ui/shell.ts4
-rw-r--r--src/view/com/composer/Composer.tsx132
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx12
-rw-r--r--src/view/com/composer/photos/Gallery.tsx130
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx58
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.web.tsx3
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx84
-rw-r--r--src/view/com/composer/photos/SelectedPhotos.tsx96
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx111
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx42
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts8
-rw-r--r--src/view/com/modals/EditProfile.tsx14
-rw-r--r--src/view/com/modals/crop-image/CropImage.web.tsx12
-rw-r--r--src/view/com/util/UserAvatar.tsx16
-rw-r--r--src/view/com/util/UserBanner.tsx15
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx12
29 files changed, 856 insertions, 817 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 457921d69..1b12f29c5 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -10,8 +10,8 @@ import {
 import {AtUri} from '@atproto/api'
 import {RootStoreModel} from 'state/models/root-store'
 import {isNetworkError} from 'lib/strings/errors'
+import {Image} from 'lib/media/types'
 import {LinkMeta} from '../link-meta/link-meta'
-import {Image} from '../media/manip'
 import {isWeb} from 'platform/detection'
 
 export interface ExternalEmbedDraft {
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 0cde9b014..d49d8c75c 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -161,6 +161,8 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) {
   }
 }
 
-export const POST_IMG_MAX_WIDTH = 2000
-export const POST_IMG_MAX_HEIGHT = 2000
-export const POST_IMG_MAX_SIZE = 1000000
+export const POST_IMG_MAX = {
+  width: 2000,
+  height: 2000,
+  size: 1000000,
+}
diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts
index 6ff8b691c..f77b861e2 100644
--- a/src/lib/media/manip.ts
+++ b/src/lib/media/manip.ts
@@ -1,13 +1,77 @@
 import RNFetchBlob from 'rn-fetch-blob'
 import ImageResizer from '@bam.tech/react-native-image-resizer'
 import {Image as RNImage, Share} from 'react-native'
+import {Image} from 'react-native-image-crop-picker'
 import RNFS from 'react-native-fs'
 import uuid from 'react-native-uuid'
 import * as Toast from 'view/com/util/Toast'
+import {Dimensions} from './types'
+import {POST_IMG_MAX} from 'lib/constants'
+import {isAndroid} from 'platform/detection'
 
-export interface Dim {
-  width: number
-  height: number
+export async function compressAndResizeImageForPost(
+  image: Image,
+): Promise<Image> {
+  const uri = `file://${image.path}`
+  let resized: Omit<Image, 'mime'>
+
+  for (let i = 0; i < 9; i++) {
+    const quality = 100 - i * 10
+
+    try {
+      resized = await ImageResizer.createResizedImage(
+        uri,
+        POST_IMG_MAX.width,
+        POST_IMG_MAX.height,
+        'JPEG',
+        quality,
+        undefined,
+        undefined,
+        undefined,
+        {mode: 'cover'},
+      )
+    } catch (err) {
+      throw new Error(`Failed to resize: ${err}`)
+    }
+
+    if (resized.size < POST_IMG_MAX.size) {
+      const path = await moveToPermanentPath(resized.path)
+
+      return {
+        path,
+        mime: 'image/jpeg',
+        size: resized.size,
+        height: resized.height,
+        width: resized.width,
+      }
+    }
+  }
+
+  throw new Error(
+    `This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`,
+  )
+}
+
+export async function compressIfNeeded(
+  img: Image,
+  maxSize: number = 1000000,
+): Promise<Image> {
+  const origUri = `file://${img.path}`
+  if (img.size < maxSize) {
+    return img
+  }
+  const resizedImage = await doResize(origUri, {
+    width: img.width,
+    height: img.height,
+    mode: 'stretch',
+    maxSize,
+  })
+  const finalImageMovedPath = await moveToPermanentPath(resizedImage.path)
+  const finalImg = {
+    ...resizedImage,
+    path: finalImageMovedPath,
+  }
+  return finalImg
 }
 
 export interface DownloadAndResizeOpts {
@@ -19,14 +83,6 @@ export interface DownloadAndResizeOpts {
   timeout: number
 }
 
-export interface Image {
-  path: string
-  mime: string
-  size: number
-  width: number
-  height: number
-}
-
 export async function downloadAndResize(opts: DownloadAndResizeOpts) {
   let appendExt = 'jpeg'
   try {
@@ -55,7 +111,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
       localUri = `file://${localUri}`
     }
 
-    return await resize(localUri, opts)
+    return await doResize(localUri, opts)
   } finally {
     if (downloadRes) {
       downloadRes.flush()
@@ -63,17 +119,47 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
   }
 }
 
-export interface ResizeOpts {
+export async function saveImageModal({uri}: {uri: string}) {
+  const downloadResponse = await RNFetchBlob.config({
+    fileCache: true,
+  }).fetch('GET', uri)
+
+  const imagePath = downloadResponse.path()
+  const base64Data = await downloadResponse.readFile('base64')
+  const result = await Share.share({
+    url: 'data:image/png;base64,' + base64Data,
+  })
+  if (result.action === Share.sharedAction) {
+    Toast.show('Image saved to gallery')
+  } else if (result.action === Share.dismissedAction) {
+    // dismissed
+  }
+  RNFS.unlink(imagePath)
+}
+
+export function getImageDim(path: string): Promise<Dimensions> {
+  return new Promise((resolve, reject) => {
+    RNImage.getSize(
+      path,
+      (width, height) => {
+        resolve({width, height})
+      },
+      reject,
+    )
+  })
+}
+
+// internal methods
+// =
+
+interface DoResizeOpts {
   width: number
   height: number
   mode: 'contain' | 'cover' | 'stretch'
   maxSize: number
 }
 
-export async function resize(
-  localUri: string,
-  opts: ResizeOpts,
-): Promise<Image> {
+async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
   for (let i = 0; i < 9; i++) {
     const quality = 100 - i * 10
     const resizeRes = await ImageResizer.createResizedImage(
@@ -89,7 +175,7 @@ export async function resize(
     )
     if (resizeRes.size < opts.maxSize) {
       return {
-        path: resizeRes.path,
+        path: normalizePath(resizeRes.path),
         mime: 'image/jpeg',
         size: resizeRes.size,
         width: resizeRes.width,
@@ -102,78 +188,24 @@ export async function resize(
   )
 }
 
-export async function compressIfNeeded(
-  img: Image,
-  maxSize: number,
-): Promise<Image> {
-  const origUri = `file://${img.path}`
-  if (img.size < maxSize) {
-    return img
-  }
-  const resizedImage = await resize(origUri, {
-    width: img.width,
-    height: img.height,
-    mode: 'stretch',
-    maxSize,
-  })
-  const finalImageMovedPath = await moveToPremanantPath(resizedImage.path)
-  const finalImg = {
-    ...resizedImage,
-    path: finalImageMovedPath,
-  }
-  return finalImg
-}
-
-export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
-  if (dim.width < max.width && dim.height < max.height) {
-    return dim
-  }
-  let wScale = dim.width > max.width ? max.width / dim.width : 1
-  let hScale = dim.height > max.height ? max.height / dim.height : 1
-  if (wScale < hScale) {
-    return {width: dim.width * wScale, height: dim.height * wScale}
-  }
-  return {width: dim.width * hScale, height: dim.height * hScale}
-}
-
-export async function saveImageModal({uri}: {uri: string}) {
-  const downloadResponse = await RNFetchBlob.config({
-    fileCache: true,
-  }).fetch('GET', uri)
-
-  const imagePath = downloadResponse.path()
-  const base64Data = await downloadResponse.readFile('base64')
-  const result = await Share.share({
-    url: 'data:image/png;base64,' + base64Data,
-  })
-  if (result.action === Share.sharedAction) {
-    Toast.show('Image saved to gallery')
-  } else if (result.action === Share.dismissedAction) {
-    // dismissed
-  }
-  RNFS.unlink(imagePath)
-}
-
-export async function moveToPremanantPath(path: string) {
+async function moveToPermanentPath(path: string): Promise<string> {
   /*
   Since this package stores images in a temp directory, we need to move the file to a permanent location.
   Relevant: IOS bug when trying to open a second time:
   https://github.com/ivpusic/react-native-image-crop-picker/issues/1199
   */
   const filename = uuid.v4()
+
   const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}`
-  RNFS.moveFile(path, destinationPath)
-  return destinationPath
+  await RNFS.moveFile(path, destinationPath)
+  return normalizePath(destinationPath)
 }
 
-export function getImageDim(path: string): Promise<Dim> {
-  return new Promise((resolve, reject) => {
-    RNImage.getSize(
-      path,
-      (width, height) => {
-        resolve({width, height})
-      },
-      reject,
-    )
-  })
+function normalizePath(str: string): string {
+  if (isAndroid) {
+    if (!str.startsWith('file://')) {
+      return `file://${str}`
+    }
+  }
+  return str
 }
diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts
index cd0bb3bc9..85f6b6138 100644
--- a/src/lib/media/manip.web.ts
+++ b/src/lib/media/manip.web.ts
@@ -1,6 +1,40 @@
-// import {Share} from 'react-native'
-// import * as Toast from 'view/com/util/Toast'
-import {extractDataUriMime, getDataUriSize} from './util'
+import {Dimensions} from './types'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {getDataUriSize, blobToDataUri} from './util'
+import {POST_IMG_MAX} from 'lib/constants'
+
+export async function compressAndResizeImageForPost({
+  path,
+  width,
+  height,
+}: {
+  path: string
+  width: number
+  height: number
+}): Promise<RNImage> {
+  // Compression is handled in `doResize` via `quality`
+  return await doResize(path, {
+    width,
+    height,
+    maxSize: POST_IMG_MAX.size,
+    mode: 'stretch',
+  })
+}
+
+export async function compressIfNeeded(
+  img: RNImage,
+  maxSize: number,
+): Promise<RNImage> {
+  if (img.size < maxSize) {
+    return img
+  }
+  return await doResize(img.path, {
+    width: img.width,
+    height: img.height,
+    mode: 'stretch',
+    maxSize,
+  })
+}
 
 export interface DownloadAndResizeOpts {
   uri: string
@@ -11,14 +45,6 @@ export interface DownloadAndResizeOpts {
   timeout: number
 }
 
-export interface Image {
-  path: string
-  mime: string
-  size: number
-  width: number
-  height: number
-}
-
 export async function downloadAndResize(opts: DownloadAndResizeOpts) {
   const controller = new AbortController()
   const to = setTimeout(() => controller.abort(), opts.timeout || 5e3)
@@ -27,58 +53,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
   clearTimeout(to)
 
   const dataUri = await blobToDataUri(resBody)
-  return await resize(dataUri, opts)
-}
-
-export interface ResizeOpts {
-  width: number
-  height: number
-  mode: 'contain' | 'cover' | 'stretch'
-  maxSize: number
-}
-
-export async function resize(
-  dataUri: string,
-  _opts: ResizeOpts,
-): Promise<Image> {
-  const dim = await getImageDim(dataUri)
-  // TODO -- need to resize
-  return {
-    path: dataUri,
-    mime: extractDataUriMime(dataUri),
-    size: getDataUriSize(dataUri),
-    width: dim.width,
-    height: dim.height,
-  }
-}
-
-export async function compressIfNeeded(
-  img: Image,
-  maxSize: number,
-): Promise<Image> {
-  if (img.size > maxSize) {
-    // TODO
-    throw new Error(
-      "This image is too large and we haven't implemented compression yet -- sorry!",
-    )
-  }
-  return img
-}
-
-export interface Dim {
-  width: number
-  height: number
-}
-export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
-  if (dim.width < max.width && dim.height < max.height) {
-    return dim
-  }
-  let wScale = dim.width > max.width ? max.width / dim.width : 1
-  let hScale = dim.height > max.height ? max.height / dim.height : 1
-  if (wScale < hScale) {
-    return {width: dim.width * wScale, height: dim.height * wScale}
-  }
-  return {width: dim.width * hScale, height: dim.height * hScale}
+  return await doResize(dataUri, opts)
 }
 
 export async function saveImageModal(_opts: {uri: string}) {
@@ -86,11 +61,7 @@ export async function saveImageModal(_opts: {uri: string}) {
   throw new Error('TODO')
 }
 
-export async function moveToPremanantPath(path: string) {
-  return path
-}
-
-export async function getImageDim(path: string): Promise<Dim> {
+export async function getImageDim(path: string): Promise<Dimensions> {
   var img = document.createElement('img')
   const promise = new Promise((resolve, reject) => {
     img.onload = resolve
@@ -101,17 +72,82 @@ export async function getImageDim(path: string): Promise<Dim> {
   return {width: img.width, height: img.height}
 }
 
-function blobToDataUri(blob: Blob): Promise<string> {
+// internal methods
+// =
+
+interface DoResizeOpts {
+  width: number
+  height: number
+  mode: 'contain' | 'cover' | 'stretch'
+  maxSize: number
+}
+
+async function doResize(dataUri: string, opts: DoResizeOpts): Promise<RNImage> {
+  let newDataUri
+
+  for (let i = 0; i <= 10; i++) {
+    newDataUri = await createResizedImage(dataUri, {
+      width: opts.width,
+      height: opts.height,
+      quality: 1 - i * 0.1,
+      mode: opts.mode,
+    })
+    if (getDataUriSize(newDataUri) < opts.maxSize) {
+      break
+    }
+  }
+  if (!newDataUri) {
+    throw new Error('Failed to compress image')
+  }
+  return {
+    path: newDataUri,
+    mime: 'image/jpeg',
+    size: getDataUriSize(newDataUri),
+    width: opts.width,
+    height: opts.height,
+  }
+}
+
+function createResizedImage(
+  dataUri: string,
+  {
+    width,
+    height,
+    quality,
+    mode,
+  }: {
+    width: number
+    height: number
+    quality: number
+    mode: 'contain' | 'cover' | 'stretch'
+  },
+): Promise<string> {
   return new Promise((resolve, reject) => {
-    const reader = new FileReader()
-    reader.onloadend = () => {
-      if (typeof reader.result === 'string') {
-        resolve(reader.result)
-      } else {
-        reject(new Error('Failed to read blob'))
+    const img = document.createElement('img')
+    img.addEventListener('load', () => {
+      const canvas = document.createElement('canvas')
+      const ctx = canvas.getContext('2d')
+      if (!ctx) {
+        return reject(new Error('Failed to resize image'))
       }
-    }
-    reader.onerror = reject
-    reader.readAsDataURL(blob)
+
+      canvas.width = width
+      canvas.height = height
+
+      let scale = 1
+      if (mode === 'cover') {
+        scale = img.width < img.height ? width / img.width : height / img.height
+      } else if (mode === 'contain') {
+        scale = img.width > img.height ? width / img.width : height / img.height
+      }
+      let w = img.width * scale
+      let h = img.height * scale
+      let x = (width - w) / 2
+      let y = (height - h) / 2
+
+      ctx.drawImage(img, x, y, w, h)
+      resolve(canvas.toDataURL('image/jpeg', quality))
+    })
+    img.src = dataUri
   })
 }
diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx
index 9f4765ac2..e53dc42be 100644
--- a/src/lib/media/picker.e2e.tsx
+++ b/src/lib/media/picker.e2e.tsx
@@ -1,13 +1,8 @@
 import {RootStoreModel} from 'state/index'
-import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
-import {
-  scaleDownDimensions,
-  Dim,
-  compressIfNeeded,
-  moveToPremanantPath,
-} from 'lib/media/manip'
-export type {PickedMedia} from './types'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 import RNFS from 'react-native-fs'
+import {CropperOptions} from './types'
+import {compressAndResizeImageForPost} from './manip'
 
 let _imageCounter = 0
 async function getFile() {
@@ -17,100 +12,33 @@ async function getFile() {
       .concat(['Media', 'DCIM', '100APPLE'])
       .join('/'),
   )
-  return files[_imageCounter++ % files.length]
-}
-
-export async function openPicker(
-  _store: RootStoreModel,
-  opts: PickerOpts,
-): Promise<PickedMedia[]> {
-  const mediaType = opts.mediaType || 'photo'
-  const items = await getFile()
-  const toMedia = (item: RNFS.ReadDirItem) => ({
-    mediaType,
-    path: item.path,
+  const file = files[_imageCounter++ % files.length]
+  return await compressAndResizeImageForPost({
+    path: file.path,
     mime: 'image/jpeg',
-    size: item.size,
+    size: file.size,
     width: 4288,
     height: 2848,
   })
-  if (Array.isArray(items)) {
-    return items.map(toMedia)
-  }
-  return [toMedia(items)]
 }
 
-export async function openCamera(
-  _store: RootStoreModel,
-  opts: CameraOpts,
-): Promise<PickedMedia> {
-  const mediaType = opts.mediaType || 'photo'
-  const item = await getFile()
-  return {
-    mediaType,
-    path: item.path,
-    mime: 'image/jpeg',
-    size: item.size,
-    width: 4288,
-    height: 2848,
-  }
+export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> {
+  return [await getFile()]
+}
+
+export async function openCamera(_store: RootStoreModel): Promise<RNImage> {
+  return await getFile()
 }
 
 export async function openCropper(
   _store: RootStoreModel,
-  opts: CropperOpts,
-): Promise<PickedMedia> {
-  const mediaType = opts.mediaType || 'photo'
-  const item = await getFile()
+  opts: CropperOptions,
+): Promise<RNImage> {
   return {
-    mediaType,
-    path: item.path,
+    path: opts.path,
     mime: 'image/jpeg',
-    size: item.size,
+    size: 123,
     width: 4288,
     height: 2848,
   }
 }
-
-export async function pickImagesFlow(
-  store: RootStoreModel,
-  maxFiles: number,
-  maxDim: Dim,
-  maxSize: number,
-) {
-  const items = await openPicker(store, {
-    multiple: true,
-    maxFiles,
-    mediaType: 'photo',
-  })
-  const result = []
-  for (const image of items) {
-    result.push(
-      await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
-    )
-  }
-  return result
-}
-
-export async function cropAndCompressFlow(
-  store: RootStoreModel,
-  path: string,
-  imgDim: Dim,
-  maxDim: Dim,
-  maxSize: number,
-) {
-  // choose target dimensions based on the original
-  // this causes the photo cropper to start with the full image "selected"
-  const {width, height} = scaleDownDimensions(imgDim, maxDim)
-  const cropperRes = await openCropper(store, {
-    mediaType: 'photo',
-    path,
-    freeStyleCropEnabled: true,
-    width,
-    height,
-  })
-
-  const img = await compressIfNeeded(cropperRes, maxSize)
-  const permanentPath = await moveToPremanantPath(img.path)
-  return permanentPath
-}
diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx
index 70a5d9068..af4a3e4d3 100644
--- a/src/lib/media/picker.tsx
+++ b/src/lib/media/picker.tsx
@@ -5,14 +5,8 @@ import {
   ImageOrVideo,
 } from 'react-native-image-crop-picker'
 import {RootStoreModel} from 'state/index'
-import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
-import {
-  scaleDownDimensions,
-  Dim,
-  compressIfNeeded,
-  moveToPremanantPath,
-} from 'lib/media/manip'
-export type {PickedMedia} from './types'
+import {PickerOpts, CameraOpts, CropperOptions} from './types'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 
 /**
  * NOTE
@@ -25,18 +19,17 @@ export type {PickedMedia} from './types'
 
 export async function openPicker(
   _store: RootStoreModel,
-  opts: PickerOpts,
-): Promise<PickedMedia[]> {
-  const mediaType = opts.mediaType || 'photo'
+  opts?: PickerOpts,
+): Promise<RNImage[]> {
   const items = await openPickerFn({
-    mediaType,
-    multiple: opts.multiple,
-    maxFiles: opts.maxFiles,
+    mediaType: 'photo', // TODO: eventually add other media types
+    multiple: opts?.multiple,
+    maxFiles: opts?.maxFiles,
     forceJpg: true, // ios only
     compressImageQuality: 0.8,
   })
+
   const toMedia = (item: ImageOrVideo) => ({
-    mediaType,
     path: item.path,
     mime: item.mime,
     size: item.size,
@@ -52,20 +45,17 @@ export async function openPicker(
 export async function openCamera(
   _store: RootStoreModel,
   opts: CameraOpts,
-): Promise<PickedMedia> {
-  const mediaType = opts.mediaType || 'photo'
+): Promise<RNImage> {
   const item = await openCameraFn({
-    mediaType,
     width: opts.width,
     height: opts.height,
     freeStyleCropEnabled: opts.freeStyleCropEnabled,
     cropperCircleOverlay: opts.cropperCircleOverlay,
-    cropping: true,
+    cropping: false,
     forceJpg: true, // ios only
     compressImageQuality: 0.8,
   })
   return {
-    mediaType,
     path: item.path,
     mime: item.mime,
     size: item.size,
@@ -76,21 +66,15 @@ export async function openCamera(
 
 export async function openCropper(
   _store: RootStoreModel,
-  opts: CropperOpts,
-): Promise<PickedMedia> {
-  const mediaType = opts.mediaType || 'photo'
+  opts: CropperOptions,
+): Promise<RNImage> {
   const item = await openCropperFn({
-    path: opts.path,
-    mediaType: opts.mediaType || 'photo',
-    width: opts.width,
-    height: opts.height,
-    freeStyleCropEnabled: opts.freeStyleCropEnabled,
-    cropperCircleOverlay: opts.cropperCircleOverlay,
+    ...opts,
     forceJpg: true, // ios only
     compressImageQuality: 0.8,
   })
+
   return {
-    mediaType,
     path: item.path,
     mime: item.mime,
     size: item.size,
@@ -98,46 +82,3 @@ export async function openCropper(
     height: item.height,
   }
 }
-
-export async function pickImagesFlow(
-  store: RootStoreModel,
-  maxFiles: number,
-  maxDim: Dim,
-  maxSize: number,
-) {
-  const items = await openPicker(store, {
-    multiple: true,
-    maxFiles,
-    mediaType: 'photo',
-  })
-  const result = []
-  for (const image of items) {
-    result.push(
-      await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
-    )
-  }
-  return result
-}
-
-export async function cropAndCompressFlow(
-  store: RootStoreModel,
-  path: string,
-  imgDim: Dim,
-  maxDim: Dim,
-  maxSize: number,
-) {
-  // choose target dimensions based on the original
-  // this causes the photo cropper to start with the full image "selected"
-  const {width, height} = scaleDownDimensions(imgDim, maxDim)
-  const cropperRes = await openCropper(store, {
-    mediaType: 'photo',
-    path,
-    freeStyleCropEnabled: true,
-    width,
-    height,
-  })
-
-  const img = await compressIfNeeded(cropperRes, maxSize)
-  const permanentPath = await moveToPremanantPath(img.path)
-  return permanentPath
-}
diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx
index 158c37971..3a9869985 100644
--- a/src/lib/media/picker.web.tsx
+++ b/src/lib/media/picker.web.tsx
@@ -1,16 +1,10 @@
 /// <reference lib="dom" />
 
-import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
-export type {PickedMedia} from './types'
+import {PickerOpts, CameraOpts, CropperOptions} from './types'
 import {RootStoreModel} from 'state/index'
-import {
-  scaleDownDimensions,
-  getImageDim,
-  Dim,
-  compressIfNeeded,
-  moveToPremanantPath,
-} from 'lib/media/manip'
+import {getImageDim} from 'lib/media/manip'
 import {extractDataUriMime} from './util'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 
 interface PickedFile {
   uri: string
@@ -21,13 +15,12 @@ interface PickedFile {
 export async function openPicker(
   _store: RootStoreModel,
   opts: PickerOpts,
-): Promise<PickedMedia[]> {
+): Promise<RNImage[]> {
   const res = await selectFile(opts)
   const dim = await getImageDim(res.uri)
   const mime = extractDataUriMime(res.uri)
   return [
     {
-      mediaType: 'photo',
       path: res.uri,
       mime,
       size: res.size,
@@ -40,21 +33,21 @@ export async function openPicker(
 export async function openCamera(
   _store: RootStoreModel,
   _opts: CameraOpts,
-): Promise<PickedMedia> {
+): Promise<RNImage> {
   // const mediaType = opts.mediaType || 'photo' TODO
   throw new Error('TODO')
 }
 
 export async function openCropper(
   store: RootStoreModel,
-  opts: CropperOpts,
-): Promise<PickedMedia> {
+  opts: CropperOptions,
+): Promise<RNImage> {
   // TODO handle more opts
   return new Promise((resolve, reject) => {
     store.shell.openModal({
       name: 'crop-image',
       uri: opts.path,
-      onSelect: (img?: PickedMedia) => {
+      onSelect: (img?: RNImage) => {
         if (img) {
           resolve(img)
         } else {
@@ -65,52 +58,6 @@ export async function openCropper(
   })
 }
 
-export async function pickImagesFlow(
-  store: RootStoreModel,
-  maxFiles: number,
-  maxDim: Dim,
-  maxSize: number,
-) {
-  const items = await openPicker(store, {
-    multiple: true,
-    maxFiles,
-    mediaType: 'photo',
-  })
-  const result = []
-  for (const image of items) {
-    result.push(
-      await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
-    )
-  }
-  return result
-}
-
-export async function cropAndCompressFlow(
-  store: RootStoreModel,
-  path: string,
-  imgDim: Dim,
-  maxDim: Dim,
-  maxSize: number,
-) {
-  // choose target dimensions based on the original
-  // this causes the photo cropper to start with the full image "selected"
-  const {width, height} = scaleDownDimensions(imgDim, maxDim)
-  const cropperRes = await openCropper(store, {
-    mediaType: 'photo',
-    path,
-    freeStyleCropEnabled: true,
-    width,
-    height,
-  })
-
-  const img = await compressIfNeeded(cropperRes, maxSize)
-  const permanentPath = await moveToPremanantPath(img.path)
-  return permanentPath
-}
-
-// helpers
-// =
-
 /**
  * Opens the select file dialog in the browser.
  * NOTE:
diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts
index 3197b4d3e..e6f442759 100644
--- a/src/lib/media/types.ts
+++ b/src/lib/media/types.ts
@@ -1,31 +1,21 @@
+import {openCropper} from 'react-native-image-crop-picker'
+
+export interface Dimensions {
+  width: number
+  height: number
+}
+
 export interface PickerOpts {
-  mediaType?: 'photo'
+  mediaType?: string
   multiple?: boolean
   maxFiles?: number
 }
 
 export interface CameraOpts {
-  mediaType?: 'photo'
-  width: number
-  height: number
-  freeStyleCropEnabled?: boolean
-  cropperCircleOverlay?: boolean
-}
-
-export interface CropperOpts {
-  path: string
-  mediaType?: 'photo'
   width: number
   height: number
   freeStyleCropEnabled?: boolean
   cropperCircleOverlay?: boolean
 }
 
-export interface PickedMedia {
-  mediaType: 'photo'
-  path: string
-  mime: string
-  size: number
-  width: number
-  height: number
-}
+export type CropperOptions = Parameters<typeof openCropper>[0]
diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts
index a27c71d82..75915de6b 100644
--- a/src/lib/media/util.ts
+++ b/src/lib/media/util.ts
@@ -1,7 +1,45 @@
+import {Dimensions} from './types'
+
 export function extractDataUriMime(uri: string): string {
   return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
 }
 
+// Fairly accurate estimate that is more performant
+// than decoding and checking length of URI
 export function getDataUriSize(uri: string): number {
-  return Math.round((uri.length * 3) / 4) // very rough estimate
+  return Math.round((uri.length * 3) / 4)
+}
+
+export function scaleDownDimensions(
+  dim: Dimensions,
+  max: Dimensions,
+): Dimensions {
+  if (dim.width < max.width && dim.height < max.height) {
+    return dim
+  }
+  const wScale = dim.width > max.width ? max.width / dim.width : 1
+  const hScale = dim.height > max.height ? max.height / dim.height : 1
+  if (wScale < hScale) {
+    return {width: dim.width * wScale, height: dim.height * wScale}
+  }
+  return {width: dim.width * hScale, height: dim.height * hScale}
+}
+
+export function isUriImage(uri: string) {
+  return /\.(jpg|jpeg|png).*$/.test(uri)
+}
+
+export function blobToDataUri(blob: Blob): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onloadend = () => {
+      if (typeof reader.result === 'string') {
+        resolve(reader.result)
+      } else {
+        reject(new Error('Failed to read blob'))
+      }
+    }
+    reader.onerror = reject
+    reader.readAsDataURL(blob)
+  })
 }
diff --git a/src/state/models/cache/image-sizes.ts b/src/state/models/cache/image-sizes.ts
index 2fd6e0013..bbfb9612b 100644
--- a/src/state/models/cache/image-sizes.ts
+++ b/src/state/models/cache/image-sizes.ts
@@ -1,24 +1,24 @@
 import {Image} from 'react-native'
-import {Dim} from 'lib/media/manip'
+import type {Dimensions} from 'lib/media/types'
 
 export class ImageSizesCache {
-  sizes: Map<string, Dim> = new Map()
-  activeRequests: Map<string, Promise<Dim>> = new Map()
+  sizes: Map<string, Dimensions> = new Map()
+  activeRequests: Map<string, Promise<Dimensions>> = new Map()
 
   constructor() {}
 
-  get(uri: string): Dim | undefined {
+  get(uri: string): Dimensions | undefined {
     return this.sizes.get(uri)
   }
 
-  async fetch(uri: string): Promise<Dim> {
-    const dim = this.sizes.get(uri)
-    if (dim) {
-      return dim
+  async fetch(uri: string): Promise<Dimensions> {
+    const Dimensions = this.sizes.get(uri)
+    if (Dimensions) {
+      return Dimensions
     }
     const prom =
       this.activeRequests.get(uri) ||
-      new Promise<Dim>(resolve => {
+      new Promise<Dimensions>(resolve => {
         Image.getSize(
           uri,
           (width: number, height: number) => resolve({width, height}),
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 45d928c92..c26dc8749 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -1,5 +1,4 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {PickedMedia} from 'lib/media/picker'
 import {
   ComAtprotoLabelDefs,
   AppBskyActorGetProfile as GetProfile,
@@ -10,6 +9,7 @@ import {RootStoreModel} from '../root-store'
 import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
 import {FollowState} from '../cache/my-follows'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 
 export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
 
@@ -122,8 +122,8 @@ export class ProfileModel {
 
   async updateProfile(
     updates: AppBskyActorProfile.Record,
-    newUserAvatar: PickedMedia | undefined | null,
-    newUserBanner: PickedMedia | undefined | null,
+    newUserAvatar: RNImage | undefined | null,
+    newUserBanner: RNImage | undefined | null,
   ) {
     await this.rootStore.agent.upsertProfile(async existing => {
       existing = existing || {}
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
new file mode 100644
index 000000000..fbe6c92a0
--- /dev/null
+++ b/src/state/models/media/gallery.ts
@@ -0,0 +1,85 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {RootStoreModel} from 'state/index'
+import {ImageModel} from './image'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {openPicker} from 'lib/media/picker'
+import {getImageDim} from 'lib/media/manip'
+import {getDataUriSize} from 'lib/media/util'
+
+export class GalleryModel {
+  images: ImageModel[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(this, {
+      rootStore: false,
+    })
+  }
+
+  get isEmpty() {
+    return this.size === 0
+  }
+
+  get size() {
+    return this.images.length
+  }
+
+  get paths() {
+    return this.images.map(image =>
+      image.compressed === undefined ? image.path : image.compressed.path,
+    )
+  }
+
+  async add(image_: RNImage) {
+    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(this.rootStore, image_)
+      await image.compress()
+
+      runInAction(() => {
+        this.images.push(image)
+      })
+    }
+  }
+
+  async paste(uri: string) {
+    if (this.size >= 4) {
+      return
+    }
+
+    const {width, height} = await getImageDim(uri)
+
+    const image: RNImage = {
+      path: uri,
+      height,
+      width,
+      size: getDataUriSize(uri),
+      mime: 'image/jpeg',
+    }
+
+    runInAction(() => {
+      this.add(image)
+    })
+  }
+
+  crop(image: ImageModel) {
+    image.crop()
+  }
+
+  remove(image: ImageModel) {
+    const index = this.images.findIndex(image_ => image_.path === image.path)
+    this.images.splice(index, 1)
+  }
+
+  async pick() {
+    const images = await openPicker(this.rootStore, {
+      multiple: true,
+      maxFiles: 4 - this.images.length,
+    })
+
+    await Promise.all(images.map(image => this.add(image)))
+  }
+}
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
new file mode 100644
index 000000000..584bf90cc
--- /dev/null
+++ b/src/state/models/media/image.ts
@@ -0,0 +1,85 @@
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {RootStoreModel} from 'state/index'
+import {compressAndResizeImageForPost} from 'lib/media/manip'
+import {makeAutoObservable, runInAction} from 'mobx'
+import {openCropper} from 'lib/media/picker'
+import {POST_IMG_MAX} from 'lib/constants'
+import {scaleDownDimensions} from 'lib/media/util'
+
+// TODO: EXIF embed
+// Cases to consider: ExternalEmbed
+export class ImageModel implements RNImage {
+  path: string
+  mime = 'image/jpeg'
+  width: number
+  height: number
+  size: number
+  cropped?: RNImage = undefined
+  compressed?: RNImage = undefined
+  scaledWidth: number = POST_IMG_MAX.width
+  scaledHeight: number = POST_IMG_MAX.height
+
+  constructor(public rootStore: RootStoreModel, image: RNImage) {
+    makeAutoObservable(this, {
+      rootStore: false,
+    })
+
+    this.path = image.path
+    this.width = image.width
+    this.height = image.height
+    this.size = image.size
+    this.calcScaledDimensions()
+  }
+
+  calcScaledDimensions() {
+    const {width, height} = scaleDownDimensions(
+      {width: this.width, height: this.height},
+      POST_IMG_MAX,
+    )
+
+    this.scaledWidth = width
+    this.scaledHeight = height
+  }
+
+  async crop() {
+    try {
+      const cropped = await openCropper(this.rootStore, {
+        mediaType: 'photo',
+        path: this.path,
+        freeStyleCropEnabled: true,
+        width: this.scaledWidth,
+        height: this.scaledHeight,
+      })
+
+      runInAction(() => {
+        this.cropped = cropped
+      })
+    } catch (err) {
+      this.rootStore.log.error('Failed to crop photo', err)
+    }
+
+    this.compress()
+  }
+
+  async compress() {
+    try {
+      const {width, height} = scaleDownDimensions(
+        this.cropped
+          ? {width: this.cropped.width, height: this.cropped.height}
+          : {width: this.width, height: this.height},
+        POST_IMG_MAX,
+      )
+      const compressed = await compressAndResizeImageForPost({
+        ...(this.cropped === undefined ? this : this.cropped),
+        width,
+        height,
+      })
+
+      runInAction(() => {
+        this.compressed = compressed
+      })
+    } catch (err) {
+      this.rootStore.log.error('Failed to compress photo', err)
+    }
+  }
+}
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index dd5c899b3..47cc0aa82 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -3,7 +3,7 @@ import {RootStoreModel} from '../root-store'
 import {makeAutoObservable} from 'mobx'
 import {ProfileModel} from '../content/profile'
 import {isObj, hasProp} from 'lib/type-guards'
-import {PickedMedia} from 'lib/media/types'
+import {Image} from 'lib/media/types'
 
 export interface ConfirmModal {
   name: 'confirm'
@@ -38,7 +38,7 @@ export interface ReportAccountModal {
 export interface CropImageModal {
   name: 'crop-image'
   uri: string
-  onSelect: (img?: PickedMedia) => void
+  onSelect: (img?: Image) => void
 }
 
 export interface DeleteAccountModal {
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 7d72899fc..f77005b5e 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
 import {observer} from 'mobx-react-lite'
 import {
   ActivityIndicator,
@@ -30,47 +30,42 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {cleanError} from 'lib/strings/errors'
 import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
 import {OpenCameraBtn} from './photos/OpenCameraBtn'
-import {SelectedPhotos} from './photos/SelectedPhotos'
 import {usePalette} from 'lib/hooks/usePalette'
 import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
 import {useExternalLinkFetch} from './useExternalLinkFetch'
 import {isDesktopWeb} from 'platform/detection'
+import {GalleryModel} from 'state/models/media/gallery'
+import {Gallery} from './photos/Gallery'
 
 const MAX_GRAPHEME_LENGTH = 300
 
+type Props = ComposerOpts & {
+  onClose: () => void
+}
+
 export const ComposePost = observer(function ComposePost({
   replyTo,
   onPost,
   onClose,
   quote: initQuote,
-}: {
-  replyTo?: ComposerOpts['replyTo']
-  onPost?: ComposerOpts['onPost']
-  onClose: () => void
-  quote?: ComposerOpts['quote']
-}) {
+}: Props) {
   const {track} = useAnalytics()
   const pal = usePalette('default')
   const store = useStores()
-  const textInput = React.useRef<TextInputRef>(null)
-  const [isProcessing, setIsProcessing] = React.useState(false)
-  const [processingState, setProcessingState] = React.useState('')
-  const [error, setError] = React.useState('')
-  const [richtext, setRichText] = React.useState(new RichText({text: ''}))
-  const graphemeLength = React.useMemo(
-    () => richtext.graphemeLength,
-    [richtext],
-  )
-  const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>(
+  const textInput = useRef<TextInputRef>(null)
+  const [isProcessing, setIsProcessing] = useState(false)
+  const [processingState, setProcessingState] = useState('')
+  const [error, setError] = useState('')
+  const [richtext, setRichText] = useState(new RichText({text: ''}))
+  const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext])
+  const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
     initQuote,
   )
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
-  const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>(
-    new Set(),
-  )
-  const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([])
+  const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
+  const gallery = useMemo(() => new GalleryModel(store), [store])
 
-  const autocompleteView = React.useMemo<UserAutocompleteModel>(
+  const autocompleteView = useMemo<UserAutocompleteModel>(
     () => new UserAutocompleteModel(store),
     [store],
   )
@@ -82,17 +77,17 @@ export const ComposePost = observer(function ComposePost({
   // is focused during unmount, an exception will throw (seems that a blur method isnt implemented)
   // manually blurring before closing gets around that
   // -prf
-  const hackfixOnClose = React.useCallback(() => {
+  const hackfixOnClose = useCallback(() => {
     textInput.current?.blur()
     onClose()
   }, [textInput, onClose])
 
   // initial setup
-  React.useEffect(() => {
+  useEffect(() => {
     autocompleteView.setup()
   }, [autocompleteView])
 
-  React.useEffect(() => {
+  useEffect(() => {
     // HACK
     // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
     // -prf
@@ -109,60 +104,51 @@ export const ComposePost = observer(function ComposePost({
     }
   }, [])
 
-  const onPressContainer = React.useCallback(() => {
+  const onPressContainer = useCallback(() => {
     textInput.current?.focus()
   }, [textInput])
 
-  const onSelectPhotos = React.useCallback(
-    (photos: string[]) => {
-      track('Composer:SelectedPhotos')
-      setSelectedPhotos(photos)
-    },
-    [track, setSelectedPhotos],
-  )
-
-  const onPressAddLinkCard = React.useCallback(
+  const onPressAddLinkCard = useCallback(
     (uri: string) => {
       setExtLink({uri, isLoading: true})
     },
     [setExtLink],
   )
 
-  const onPhotoPasted = React.useCallback(
+  const onPhotoPasted = useCallback(
     async (uri: string) => {
-      if (selectedPhotos.length >= 4) {
-        return
-      }
-      onSelectPhotos([...selectedPhotos, uri])
+      track('Composer:PastedPhotos')
+      gallery.paste(uri)
     },
-    [selectedPhotos, onSelectPhotos],
+    [gallery, track],
   )
 
-  const onPressPublish = React.useCallback(async () => {
-    if (isProcessing) {
-      return
-    }
-    if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
+  const onPressPublish = useCallback(async () => {
+    if (isProcessing || richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
       return
     }
+
     setError('')
-    if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) {
+
+    if (richtext.text.trim().length === 0 && gallery.isEmpty) {
       setError('Did you want to say anything?')
       return false
     }
+
     setIsProcessing(true)
+
     try {
       await apilib.post(store, {
         rawText: richtext.text,
         replyTo: replyTo?.uri,
-        images: selectedPhotos,
+        images: gallery.paths,
         quote: quote,
         extLink: extLink,
         onStateChange: setProcessingState,
         knownHandles: autocompleteView.knownHandles,
       })
       track('Create Post', {
-        imageCount: selectedPhotos.length,
+        imageCount: gallery.size,
       })
     } catch (e: any) {
       if (extLink) {
@@ -191,34 +177,33 @@ export const ComposePost = observer(function ComposePost({
     hackfixOnClose,
     onPost,
     quote,
-    selectedPhotos,
     setExtLink,
     store,
     track,
+    gallery,
   ])
 
   const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
 
   const selectTextInputPlaceholder = replyTo
     ? 'Write your reply'
-    : selectedPhotos.length !== 0
+    : gallery.isEmpty
     ? 'Write a comment'
     : "What's up?"
 
+  const canSelectImages = gallery.size <= 4
+  const viewStyles = {
+    paddingBottom: Platform.OS === 'android' ? insets.bottom : 0,
+    paddingTop: Platform.OS === 'android' ? insets.top : 15,
+  }
+
   return (
     <KeyboardAvoidingView
       testID="composePostView"
       behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
       style={styles.outer}>
       <TouchableWithoutFeedback onPressIn={onPressContainer}>
-        <View
-          style={[
-            s.flex1,
-            {
-              paddingBottom: Platform.OS === 'android' ? insets.bottom : 0,
-              paddingTop: Platform.OS === 'android' ? insets.top : 15,
-            },
-          ]}>
+        <View style={[s.flex1, viewStyles]}>
           <View style={styles.topbar}>
             <TouchableOpacity
               testID="composerCancelButton"
@@ -301,11 +286,8 @@ export const ComposePost = observer(function ComposePost({
               />
             </View>
 
-            <SelectedPhotos
-              selectedPhotos={selectedPhotos}
-              onSelectPhotos={onSelectPhotos}
-            />
-            {selectedPhotos.length === 0 && extLink && (
+            <Gallery gallery={gallery} />
+            {gallery.isEmpty && extLink && (
               <ExternalEmbed
                 link={extLink}
                 onRemove={() => setExtLink(undefined)}
@@ -317,9 +299,7 @@ export const ComposePost = observer(function ComposePost({
               </View>
             ) : undefined}
           </ScrollView>
-          {!extLink &&
-          selectedPhotos.length === 0 &&
-          suggestedLinks.size > 0 ? (
+          {!extLink && suggestedLinks.size > 0 ? (
             <View style={s.mb5}>
               {Array.from(suggestedLinks).map(url => (
                 <TouchableOpacity
@@ -335,16 +315,12 @@ export const ComposePost = observer(function ComposePost({
             </View>
           ) : null}
           <View style={[pal.border, styles.bottomBar]}>
-            <SelectPhotoBtn
-              enabled={selectedPhotos.length < 4}
-              selectedPhotos={selectedPhotos}
-              onSelectPhotos={setSelectedPhotos}
-            />
-            <OpenCameraBtn
-              enabled={selectedPhotos.length < 4}
-              selectedPhotos={selectedPhotos}
-              onSelectPhotos={setSelectedPhotos}
-            />
+            {canSelectImages ? (
+              <>
+                <SelectPhotoBtn gallery={gallery} />
+                <OpenCameraBtn gallery={gallery} />
+              </>
+            ) : null}
             <View style={s.flex1} />
             <CharProgress count={graphemeLength} />
           </View>
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 658023330..b6a45f6a3 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -2,11 +2,10 @@ import React from 'react'
 import {
   ActivityIndicator,
   StyleSheet,
-  TouchableWithoutFeedback,
+  TouchableOpacity,
   View,
 } from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {BlurView} from '../util/BlurView'
 import {AutoSizedImage} from '../util/images/AutoSizedImage'
 import {Text} from '../util/text/Text'
 import {s} from 'lib/styles'
@@ -61,11 +60,9 @@ export const ExternalEmbed = ({
           </Text>
         )}
       </View>
-      <TouchableWithoutFeedback onPress={onRemove}>
-        <BlurView style={styles.removeBtn} blurType="dark">
-          <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
-        </BlurView>
-      </TouchableWithoutFeedback>
+      <TouchableOpacity style={styles.removeBtn} onPress={onRemove}>
+        <FontAwesomeIcon size={18} icon="xmark" style={s.white} />
+      </TouchableOpacity>
     </View>
   )
 }
@@ -92,6 +89,7 @@ const styles = StyleSheet.create({
     right: 10,
     width: 36,
     height: 36,
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
     borderRadius: 18,
     alignItems: 'center',
     justifyContent: 'center',
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
new file mode 100644
index 000000000..f4dfc88fa
--- /dev/null
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -0,0 +1,130 @@
+import React, {useCallback} from 'react'
+import {GalleryModel} from 'state/models/media/gallery'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {colors} from 'lib/styles'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {ImageModel} from 'state/models/media/image'
+import {Image} from 'expo-image'
+
+interface Props {
+  gallery: GalleryModel
+}
+
+export const Gallery = observer(function ({gallery}: Props) {
+  const getImageStyle = useCallback(() => {
+    switch (gallery.size) {
+      case 1:
+        return styles.image250
+      case 2:
+        return styles.image175
+      default:
+        return styles.image85
+    }
+  }, [gallery])
+
+  const imageStyle = getImageStyle()
+  const handleRemovePhoto = useCallback(
+    (image: ImageModel) => {
+      gallery.remove(image)
+    },
+    [gallery],
+  )
+
+  const handleEditPhoto = useCallback(
+    (image: ImageModel) => {
+      gallery.crop(image)
+    },
+    [gallery],
+  )
+
+  return !gallery.isEmpty ? (
+    <View testID="selectedPhotosView" style={styles.gallery}>
+      {gallery.images.map(image =>
+        image.compressed !== undefined ? (
+          <View
+            key={`selected-image-${image.path}`}
+            style={[styles.imageContainer, imageStyle]}>
+            <View style={styles.imageControls}>
+              <TouchableOpacity
+                testID="cropPhotoButton"
+                onPress={() => {
+                  handleEditPhoto(image)
+                }}
+                style={styles.imageControl}>
+                <FontAwesomeIcon
+                  icon="pen"
+                  size={12}
+                  style={{color: colors.white}}
+                />
+              </TouchableOpacity>
+              <TouchableOpacity
+                testID="removePhotoButton"
+                onPress={() => handleRemovePhoto(image)}
+                style={styles.imageControl}>
+                <FontAwesomeIcon
+                  icon="xmark"
+                  size={16}
+                  style={{color: colors.white}}
+                />
+              </TouchableOpacity>
+            </View>
+
+            <Image
+              testID="selectedPhotoImage"
+              style={[styles.image, imageStyle]}
+              source={{
+                uri: image.compressed.path,
+              }}
+            />
+          </View>
+        ) : null,
+      )}
+    </View>
+  ) : null
+})
+
+const styles = StyleSheet.create({
+  gallery: {
+    flex: 1,
+    flexDirection: 'row',
+    marginTop: 16,
+  },
+  imageContainer: {
+    margin: 2,
+  },
+  image: {
+    resizeMode: 'cover',
+    borderRadius: 8,
+  },
+  image250: {
+    width: 250,
+    height: 250,
+  },
+  image175: {
+    width: 175,
+    height: 175,
+  },
+  image85: {
+    width: 85,
+    height: 85,
+  },
+  imageControls: {
+    position: 'absolute',
+    display: 'flex',
+    flexDirection: 'row',
+    gap: 4,
+    top: 8,
+    right: 8,
+    zIndex: 1,
+  },
+  imageControl: {
+    width: 24,
+    height: 24,
+    borderRadius: 12,
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderWidth: 0.5,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+})
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 118728781..809c41783 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useCallback} from 'react'
 import {TouchableOpacity} from 'react-native'
 import {
   FontAwesomeIcon,
@@ -10,62 +10,44 @@ import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {isDesktopWeb} from 'platform/detection'
 import {openCamera} from 'lib/media/picker'
-import {compressIfNeeded} from 'lib/media/manip'
 import {useCameraPermission} from 'lib/hooks/usePermissions'
-import {
-  POST_IMG_MAX_WIDTH,
-  POST_IMG_MAX_HEIGHT,
-  POST_IMG_MAX_SIZE,
-} from 'lib/constants'
+import {POST_IMG_MAX} from 'lib/constants'
+import {GalleryModel} from 'state/models/media/gallery'
 
 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
 
-export function OpenCameraBtn({
-  enabled,
-  selectedPhotos,
-  onSelectPhotos,
-}: {
-  enabled: boolean
-  selectedPhotos: string[]
-  onSelectPhotos: (v: string[]) => void
-}) {
+type Props = {
+  gallery: GalleryModel
+}
+
+export function OpenCameraBtn({gallery}: Props) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
   const store = useStores()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
 
-  const onPressTakePicture = React.useCallback(async () => {
+  const onPressTakePicture = useCallback(async () => {
     track('Composer:CameraOpened')
-    if (!enabled) {
-      return
-    }
     try {
       if (!(await requestCameraAccessIfNeeded())) {
         return
       }
-      const cameraRes = await openCamera(store, {
-        mediaType: 'photo',
-        width: POST_IMG_MAX_WIDTH,
-        height: POST_IMG_MAX_HEIGHT,
+
+      const img = await openCamera(store, {
+        width: POST_IMG_MAX.width,
+        height: POST_IMG_MAX.height,
         freeStyleCropEnabled: true,
       })
-      const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE)
-      onSelectPhotos([...selectedPhotos, img.path])
+
+      gallery.add(img)
     } catch (err: any) {
       // ignore
       store.log.warn('Error using camera', err)
     }
-  }, [
-    track,
-    store,
-    onSelectPhotos,
-    selectedPhotos,
-    enabled,
-    requestCameraAccessIfNeeded,
-  ])
+  }, [gallery, track, store, requestCameraAccessIfNeeded])
 
   if (isDesktopWeb) {
-    return <></>
+    return null
   }
 
   return (
@@ -76,11 +58,7 @@ export function OpenCameraBtn({
       hitSlop={HITSLOP}>
       <FontAwesomeIcon
         icon="camera"
-        style={
-          (enabled
-            ? pal.link
-            : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
-        }
+        style={pal.link as FontAwesomeIconStyle}
         size={24}
       />
     </TouchableOpacity>
diff --git a/src/view/com/composer/photos/OpenCameraBtn.web.tsx b/src/view/com/composer/photos/OpenCameraBtn.web.tsx
new file mode 100644
index 000000000..226de1f60
--- /dev/null
+++ b/src/view/com/composer/photos/OpenCameraBtn.web.tsx
@@ -0,0 +1,3 @@
+export function OpenCameraBtn() {
+  return null
+}
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index c0808b85c..9569e08ad 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -1,86 +1,36 @@
-import React from 'react'
-import {Platform, TouchableOpacity} from 'react-native'
+import React, {useCallback} from 'react'
+import {TouchableOpacity} from 'react-native'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics'
-import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {isDesktopWeb} from 'platform/detection'
-import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker'
 import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
-import {
-  POST_IMG_MAX_WIDTH,
-  POST_IMG_MAX_HEIGHT,
-  POST_IMG_MAX_SIZE,
-} from 'lib/constants'
+import {GalleryModel} from 'state/models/media/gallery'
 
 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
 
-export function SelectPhotoBtn({
-  enabled,
-  selectedPhotos,
-  onSelectPhotos,
-}: {
-  enabled: boolean
-  selectedPhotos: string[]
-  onSelectPhotos: (v: string[]) => void
-}) {
+type Props = {
+  gallery: GalleryModel
+}
+
+export function SelectPhotoBtn({gallery}: Props) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
-  const store = useStores()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
 
-  const onPressSelectPhotos = React.useCallback(async () => {
+  const onPressSelectPhotos = useCallback(async () => {
     track('Composer:GalleryOpened')
-    if (!enabled) {
+
+    if (!isDesktopWeb && !(await requestPhotoAccessIfNeeded())) {
       return
     }
-    if (isDesktopWeb) {
-      const images = await pickImagesFlow(
-        store,
-        4 - selectedPhotos.length,
-        {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
-        POST_IMG_MAX_SIZE,
-      )
-      onSelectPhotos([...selectedPhotos, ...images])
-    } else {
-      if (!(await requestPhotoAccessIfNeeded())) {
-        return
-      }
-      const items = await openPicker(store, {
-        multiple: true,
-        maxFiles: 4 - selectedPhotos.length,
-        mediaType: 'photo',
-      })
-      const result = []
-      for (const image of items) {
-        if (Platform.OS === 'android') {
-          result.push(image.path)
-          continue
-        }
-        result.push(
-          await cropAndCompressFlow(
-            store,
-            image.path,
-            image,
-            {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
-            POST_IMG_MAX_SIZE,
-          ),
-        )
-      }
-      onSelectPhotos([...selectedPhotos, ...result])
-    }
-  }, [
-    track,
-    store,
-    onSelectPhotos,
-    selectedPhotos,
-    enabled,
-    requestPhotoAccessIfNeeded,
-  ])
+
+    gallery.pick()
+  }, [track, gallery, requestPhotoAccessIfNeeded])
 
   return (
     <TouchableOpacity
@@ -90,11 +40,7 @@ export function SelectPhotoBtn({
       hitSlop={HITSLOP}>
       <FontAwesomeIcon
         icon={['far', 'image']}
-        style={
-          (enabled
-            ? pal.link
-            : [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
-        }
+        style={pal.link as FontAwesomeIconStyle}
         size={24}
       />
     </TouchableOpacity>
diff --git a/src/view/com/composer/photos/SelectedPhotos.tsx b/src/view/com/composer/photos/SelectedPhotos.tsx
deleted file mode 100644
index d22f5d8c4..000000000
--- a/src/view/com/composer/photos/SelectedPhotos.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import React, {useCallback} from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Image} from 'expo-image'
-import {colors} from 'lib/styles'
-
-export const SelectedPhotos = ({
-  selectedPhotos,
-  onSelectPhotos,
-}: {
-  selectedPhotos: string[]
-  onSelectPhotos: (v: string[]) => void
-}) => {
-  const imageStyle =
-    selectedPhotos.length === 1
-      ? styles.image250
-      : selectedPhotos.length === 2
-      ? styles.image175
-      : styles.image85
-
-  const handleRemovePhoto = useCallback(
-    item => {
-      onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item))
-    },
-    [selectedPhotos, onSelectPhotos],
-  )
-
-  return selectedPhotos.length !== 0 ? (
-    <View testID="selectedPhotosView" style={styles.gallery}>
-      {selectedPhotos.length !== 0 &&
-        selectedPhotos.map((item, index) => (
-          <View
-            key={`selected-image-${index}`}
-            style={[styles.imageContainer, imageStyle]}>
-            <TouchableOpacity
-              testID="removePhotoButton"
-              onPress={() => handleRemovePhoto(item)}
-              style={styles.removePhotoButton}>
-              <FontAwesomeIcon
-                icon="xmark"
-                size={16}
-                style={{color: colors.white}}
-              />
-            </TouchableOpacity>
-
-            <Image
-              testID="selectedPhotoImage"
-              style={[styles.image, imageStyle]}
-              source={{uri: item}}
-            />
-          </View>
-        ))}
-    </View>
-  ) : null
-}
-
-const styles = StyleSheet.create({
-  gallery: {
-    flex: 1,
-    flexDirection: 'row',
-    marginTop: 16,
-  },
-  imageContainer: {
-    margin: 2,
-  },
-  image: {
-    resizeMode: 'cover',
-    borderRadius: 8,
-  },
-  image250: {
-    width: 250,
-    height: 250,
-  },
-  image175: {
-    width: 175,
-    height: 175,
-  },
-  image85: {
-    width: 85,
-    height: 85,
-  },
-  removePhotoButton: {
-    position: 'absolute',
-    top: 8,
-    right: 8,
-    width: 24,
-    height: 24,
-    borderRadius: 12,
-    alignItems: 'center',
-    justifyContent: 'center',
-    backgroundColor: colors.black,
-    zIndex: 1,
-    borderColor: colors.gray4,
-    borderWidth: 0.5,
-  },
-})
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index bd536e1c3..9c111bd38 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {forwardRef, useCallback, useEffect, useRef, useMemo} from 'react'
 import {
   NativeSyntheticEvent,
   StyleSheet,
@@ -14,18 +14,13 @@ import isEqual from 'lodash.isequal'
 import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {Autocomplete} from './mobile/Autocomplete'
 import {Text} from 'view/com/util/text/Text'
-import {useStores} from 'state/index'
 import {cleanError} from 'lib/strings/errors'
-import {getImageDim} from 'lib/media/manip'
-import {cropAndCompressFlow} from 'lib/media/picker'
 import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
-import {
-  POST_IMG_MAX_WIDTH,
-  POST_IMG_MAX_HEIGHT,
-  POST_IMG_MAX_SIZE,
-} from 'lib/constants'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
+import {isUriImage} from 'lib/media/util'
+import {downloadAndResize} from 'lib/media/manip'
+import {POST_IMG_MAX} from 'lib/constants'
 
 export interface TextInputRef {
   focus: () => void
@@ -48,7 +43,7 @@ interface Selection {
   end: number
 }
 
-export const TextInput = React.forwardRef(
+export const TextInput = forwardRef(
   (
     {
       richtext,
@@ -63,9 +58,8 @@ export const TextInput = React.forwardRef(
     ref,
   ) => {
     const pal = usePalette('default')
-    const store = useStores()
-    const textInput = React.useRef<PasteInputRef>(null)
-    const textInputSelection = React.useRef<Selection>({start: 0, end: 0})
+    const textInput = useRef<PasteInputRef>(null)
+    const textInputSelection = useRef<Selection>({start: 0, end: 0})
     const theme = useTheme()
 
     React.useImperativeHandle(ref, () => ({
@@ -73,7 +67,7 @@ export const TextInput = React.forwardRef(
       blur: () => textInput.current?.blur(),
     }))
 
-    React.useEffect(() => {
+    useEffect(() => {
       // HACK
       // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
       // -prf
@@ -90,8 +84,8 @@ export const TextInput = React.forwardRef(
       }
     }, [])
 
-    const onChangeText = React.useCallback(
-      (newText: string) => {
+    const onChangeText = useCallback(
+      async (newText: string) => {
         const newRt = new RichText({text: newText})
         newRt.detectFacetsWithoutResolution()
         setRichText(newRt)
@@ -108,50 +102,62 @@ export const TextInput = React.forwardRef(
         }
 
         const set: Set<string> = new Set()
+
         if (newRt.facets) {
           for (const facet of newRt.facets) {
             for (const feature of facet.features) {
               if (AppBskyRichtextFacet.isLink(feature)) {
-                set.add(feature.uri)
+                if (isUriImage(feature.uri)) {
+                  const res = await downloadAndResize({
+                    uri: feature.uri,
+                    width: POST_IMG_MAX.width,
+                    height: POST_IMG_MAX.height,
+                    mode: 'contain',
+                    maxSize: POST_IMG_MAX.size,
+                    timeout: 15e3,
+                  })
+
+                  if (res !== undefined) {
+                    onPhotoPasted(res.path)
+                  }
+                } else {
+                  set.add(feature.uri)
+                }
               }
             }
           }
         }
+
         if (!isEqual(set, suggestedLinks)) {
           onSuggestedLinksChanged(set)
         }
       },
-      [setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged],
+      [
+        setRichText,
+        autocompleteView,
+        suggestedLinks,
+        onSuggestedLinksChanged,
+        onPhotoPasted,
+      ],
     )
 
-    const onPaste = React.useCallback(
+    const onPaste = useCallback(
       async (err: string | undefined, files: PastedFile[]) => {
         if (err) {
           return onError(cleanError(err))
         }
+
         const uris = files.map(f => f.uri)
-        const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
-        if (imgUri) {
-          let imgDim
-          try {
-            imgDim = await getImageDim(imgUri)
-          } catch (e) {
-            imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}
-          }
-          const finalImgPath = await cropAndCompressFlow(
-            store,
-            imgUri,
-            imgDim,
-            {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
-            POST_IMG_MAX_SIZE,
-          )
-          onPhotoPasted(finalImgPath)
+        const uri = uris.find(isUriImage)
+
+        if (uri) {
+          onPhotoPasted(uri)
         }
       },
-      [store, onError, onPhotoPasted],
+      [onError, onPhotoPasted],
     )
 
-    const onSelectionChange = React.useCallback(
+    const onSelectionChange = useCallback(
       (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
         // NOTE we track the input selection using a ref to avoid excessive renders -prf
         textInputSelection.current = evt.nativeEvent.selection
@@ -159,7 +165,7 @@ export const TextInput = React.forwardRef(
       [textInputSelection],
     )
 
-    const onSelectAutocompleteItem = React.useCallback(
+    const onSelectAutocompleteItem = useCallback(
       (item: string) => {
         onChangeText(
           insertMentionAt(
@@ -173,23 +179,19 @@ export const TextInput = React.forwardRef(
       [onChangeText, richtext, autocompleteView],
     )
 
-    const textDecorated = React.useMemo(() => {
+    const textDecorated = useMemo(() => {
       let i = 0
-      return Array.from(richtext.segments()).map(segment => {
-        if (!segment.facet) {
-          return (
-            <Text key={i++} style={[pal.text, styles.textInputFormatting]}>
-              {segment.text}
-            </Text>
-          )
-        } else {
-          return (
-            <Text key={i++} style={[pal.link, styles.textInputFormatting]}>
-              {segment.text}
-            </Text>
-          )
-        }
-      })
+
+      return Array.from(richtext.segments()).map(segment => (
+        <Text
+          key={i++}
+          style={[
+            !segment.facet ? pal.text : pal.link,
+            styles.textInputFormatting,
+          ]}>
+          {segment.text}
+        </Text>
+      ))
     }, [richtext, pal.link, pal.text])
 
     return (
@@ -223,7 +225,6 @@ const styles = StyleSheet.create({
   textInput: {
     flex: 1,
     width: '100%',
-    minHeight: 80,
     padding: 5,
     paddingBottom: 20,
     marginLeft: 8,
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index ba628a3f7..e75da1791 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -12,6 +12,7 @@ import isEqual from 'lodash.isequal'
 import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {createSuggestion} from './web/Autocomplete'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
+import {isUriImage, blobToDataUri} from 'lib/media/util'
 
 export interface TextInputRef {
   focus: () => void
@@ -37,7 +38,7 @@ export const TextInput = React.forwardRef(
       suggestedLinks,
       autocompleteView,
       setRichText,
-      // onPhotoPasted, TODO
+      onPhotoPasted,
       onSuggestedLinksChanged,
     }: // onError, TODO
     TextInputProps,
@@ -72,6 +73,15 @@ export const TextInput = React.forwardRef(
           attributes: {
             class: modeClass,
           },
+          handlePaste: (_, event) => {
+            const items = event.clipboardData?.items
+
+            if (items === undefined) {
+              return
+            }
+
+            getImageFromUri(items, onPhotoPasted)
+          },
         },
         content: richtext.text.toString(),
         autofocus: true,
@@ -147,3 +157,33 @@ const styles = StyleSheet.create({
     marginBottom: 10,
   },
 })
+
+function getImageFromUri(
+  items: DataTransferItemList,
+  callback: (uri: string) => void,
+) {
+  for (let index = 0; index < items.length; index++) {
+    const item = items[index]
+    const {kind, type} = item
+
+    if (type === 'text/plain') {
+      item.getAsString(async itemString => {
+        if (isUriImage(itemString)) {
+          const response = await fetch(itemString)
+          const blob = await response.blob()
+          blobToDataUri(blob).then(callback, err => console.error(err))
+        }
+      })
+    }
+
+    if (kind === 'file') {
+      const file = item.getAsFile()
+
+      if (file instanceof Blob) {
+        blobToDataUri(new Blob([file], {type: item.type})).then(callback, err =>
+          console.error(err),
+        )
+      }
+    }
+  }
+}
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index 9cb91231c..45c2dfd0d 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -6,6 +6,7 @@ import {getPostAsQuote} from 'lib/link-meta/bsky'
 import {downloadAndResize} from 'lib/media/manip'
 import {isBskyPostUrl} from 'lib/strings/url-helpers'
 import {ComposerOpts} from 'state/models/ui/shell'
+import {POST_IMG_MAX} from 'lib/constants'
 
 export function useExternalLinkFetch({
   setQuote,
@@ -55,13 +56,12 @@ export function useExternalLinkFetch({
       return cleanup
     }
     if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
-      console.log('attempting download')
       downloadAndResize({
         uri: extLink.meta.image,
-        width: 2000,
-        height: 2000,
+        width: POST_IMG_MAX.width,
+        height: POST_IMG_MAX.height,
         mode: 'contain',
-        maxSize: 1000000,
+        maxSize: POST_IMG_MAX.size,
         timeout: 15e3,
       })
         .catch(() => undefined)
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index e6ef765af..0feae3a80 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -8,7 +8,7 @@ import {
 } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {ScrollView, TextInput} from './util'
-import {PickedMedia} from '../../../lib/media/picker'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {useStores} from 'state/index'
@@ -53,15 +53,15 @@ export function Component({
     profileView.avatar,
   )
   const [newUserBanner, setNewUserBanner] = useState<
-    PickedMedia | undefined | null
+    RNImage | undefined | null
   >()
   const [newUserAvatar, setNewUserAvatar] = useState<
-    PickedMedia | undefined | null
+    RNImage | undefined | null
   >()
   const onPressCancel = () => {
     store.shell.closeModal()
   }
-  const onSelectNewAvatar = async (img: PickedMedia | null) => {
+  const onSelectNewAvatar = async (img: RNImage | null) => {
     track('EditProfile:AvatarSelected')
     try {
       // if img is null, user selected "remove avatar"
@@ -71,13 +71,13 @@ export function Component({
         return
       }
       const finalImg = await compressIfNeeded(img, 1000000)
-      setNewUserAvatar({mediaType: 'photo', ...finalImg})
+      setNewUserAvatar(finalImg)
       setUserAvatar(finalImg.path)
     } catch (e: any) {
       setError(cleanError(e))
     }
   }
-  const onSelectNewBanner = async (img: PickedMedia | null) => {
+  const onSelectNewBanner = async (img: RNImage | null) => {
     if (!img) {
       setNewUserBanner(null)
       setUserBanner(null)
@@ -86,7 +86,7 @@ export function Component({
     track('EditProfile:BannerSelected')
     try {
       const finalImg = await compressIfNeeded(img, 1000000)
-      setNewUserBanner({mediaType: 'photo', ...finalImg})
+      setNewUserBanner(finalImg)
       setUserBanner(finalImg.path)
     } catch (e: any) {
       setError(cleanError(e))
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
index 306686557..8a9b4bf62 100644
--- a/src/view/com/modals/crop-image/CropImage.web.tsx
+++ b/src/view/com/modals/crop-image/CropImage.web.tsx
@@ -4,7 +4,7 @@ import ImageEditor from 'react-avatar-editor'
 import {Slider} from '@miblanchard/react-native-slider'
 import LinearGradient from 'react-native-linear-gradient'
 import {Text} from 'view/com/util/text/Text'
-import {PickedMedia} from 'lib/media/types'
+import {Dimensions, Image} from 'lib/media/types'
 import {getDataUriSize} from 'lib/media/util'
 import {s, gradients} from 'lib/styles'
 import {useStores} from 'state/index'
@@ -16,11 +16,8 @@ enum AspectRatio {
   Wide = 'wide',
   Tall = 'tall',
 }
-interface Dim {
-  width: number
-  height: number
-}
-const DIMS: Record<string, Dim> = {
+
+const DIMS: Record<string, Dimensions> = {
   [AspectRatio.Square]: {width: 1000, height: 1000},
   [AspectRatio.Wide]: {width: 1000, height: 750},
   [AspectRatio.Tall]: {width: 750, height: 1000},
@@ -33,7 +30,7 @@ export function Component({
   onSelect,
 }: {
   uri: string
-  onSelect: (img?: PickedMedia) => void
+  onSelect: (img?: Image) => void
 }) {
   const store = useStores()
   const pal = usePalette('default')
@@ -52,7 +49,6 @@ export function Component({
     if (canvas) {
       const dataUri = canvas.toDataURL('image/jpeg')
       onSelect({
-        mediaType: 'photo',
         path: dataUri,
         mime: 'image/jpeg',
         size: getDataUriSize(dataUri),
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index d18c2d697..e5c3cf601 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -4,12 +4,7 @@ import Svg, {Circle, Path} from 'react-native-svg'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {HighPriorityImage} from 'view/com/util/images/Image'
-import {
-  openCamera,
-  openCropper,
-  openPicker,
-  PickedMedia,
-} from '../../../lib/media/picker'
+import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 import {
   usePhotoLibraryPermission,
   useCameraPermission,
@@ -19,6 +14,7 @@ import {colors} from 'lib/styles'
 import {DropdownButton} from './forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 
 function DefaultAvatar({size}: {size: number}) {
   return (
@@ -50,7 +46,7 @@ export function UserAvatar({
   size: number
   avatar?: string | null
   hasWarning?: boolean
-  onSelectNewAvatar?: (img: PickedMedia | null) => void
+  onSelectNewAvatar?: (img: RNImage | null) => void
 }) {
   const store = useStores()
   const pal = usePalette('default')
@@ -68,7 +64,6 @@ export function UserAvatar({
         }
         onSelectNewAvatar?.(
           await openCamera(store, {
-            mediaType: 'photo',
             width: 1000,
             height: 1000,
             cropperCircleOverlay: true,
@@ -84,9 +79,8 @@ export function UserAvatar({
         if (!(await requestPhotoAccessIfNeeded())) {
           return
         }
-        const items = await openPicker(store, {
-          mediaType: 'photo',
-        })
+        const items = await openPicker(store)
+
         onSelectNewAvatar?.(
           await openCropper(store, {
             mediaType: 'photo',
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index d54b41506..40c82eaf2 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -4,12 +4,8 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {Image} from 'expo-image'
 import {colors} from 'lib/styles'
-import {
-  openCamera,
-  openCropper,
-  openPicker,
-  PickedMedia,
-} from '../../../lib/media/picker'
+import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
+import {Image as TImage} from 'lib/media/types'
 import {useStores} from 'state/index'
 import {
   usePhotoLibraryPermission,
@@ -24,7 +20,7 @@ export function UserBanner({
   onSelectNewBanner,
 }: {
   banner?: string | null
-  onSelectNewBanner?: (img: PickedMedia | null) => void
+  onSelectNewBanner?: (img: TImage | null) => void
 }) {
   const store = useStores()
   const pal = usePalette('default')
@@ -42,7 +38,6 @@ export function UserBanner({
         }
         onSelectNewBanner?.(
           await openCamera(store, {
-            mediaType: 'photo',
             // compressImageMaxWidth: 3000, TODO needed?
             width: 3000,
             // compressImageMaxHeight: 1000, TODO needed?
@@ -59,9 +54,7 @@ export function UserBanner({
         if (!(await requestPhotoAccessIfNeeded())) {
           return
         }
-        const items = await openPicker(store, {
-          mediaType: 'photo',
-        })
+        const items = await openPicker(store)
         onSelectNewBanner?.(
           await openCropper(store, {
             mediaType: 'photo',
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index 83d98eec5..7f9a6fdbd 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -1,4 +1,5 @@
-import React from 'react'
+import {Dimensions} from 'lib/media/types'
+import React, {useState} from 'react'
 import {
   LayoutChangeEvent,
   StyleProp,
@@ -11,11 +12,6 @@ import {Image, ImageStyle} from 'expo-image'
 
 export const DELAY_PRESS_IN = 500
 
-interface Dim {
-  width: number
-  height: number
-}
-
 export type ImageLayoutGridType = 'two' | 'three' | 'four'
 
 export function ImageLayoutGrid({
@@ -33,7 +29,7 @@ export function ImageLayoutGrid({
   onPressIn?: (index: number) => void
   style?: StyleProp<ViewStyle>
 }) {
-  const [containerInfo, setContainerInfo] = React.useState<Dim | undefined>()
+  const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
 
   const onLayout = (evt: LayoutChangeEvent) => {
     setContainerInfo({
@@ -71,7 +67,7 @@ function ImageLayoutGridInner({
   onPress?: (index: number) => void
   onLongPress?: (index: number) => void
   onPressIn?: (index: number) => void
-  containerInfo: Dim
+  containerInfo: Dimensions
 }) {
   const size1 = React.useMemo<ImageStyle>(() => {
     if (type === 'three') {