about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/media/manip.ts25
-rw-r--r--src/lib/media/manip.web.ts14
-rw-r--r--src/lib/media/picker.e2e.tsx17
-rw-r--r--src/lib/media/picker.shared.ts11
-rw-r--r--src/lib/media/picker.tsx48
-rw-r--r--src/lib/media/picker.web.tsx26
-rw-r--r--src/lib/media/types.ts7
-rw-r--r--src/screens/Onboarding/StepProfile/index.tsx8
-rw-r--r--src/screens/Profile/Header/EditProfileDialog.tsx10
-rw-r--r--src/state/gallery.ts16
-rw-r--r--src/state/modals/index.tsx4
-rw-r--r--src/state/queries/list.ts22
-rw-r--r--src/state/queries/profile.ts6
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx4
-rw-r--r--src/view/com/modals/CreateOrEditList.tsx6
-rw-r--r--src/view/com/modals/CropImage.web.tsx6
-rw-r--r--src/view/com/modals/EditProfile.tsx10
-rw-r--r--src/view/com/util/UserAvatar.tsx31
-rw-r--r--src/view/com/util/UserBanner.tsx20
19 files changed, 139 insertions, 152 deletions
diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts
index f6ef8347d..ff5b71ace 100644
--- a/src/lib/media/manip.ts
+++ b/src/lib/media/manip.ts
@@ -1,5 +1,4 @@
 import {Image as RNImage, Share as RNShare} from 'react-native'
-import {Image} from 'react-native-image-crop-picker'
 import uuid from 'react-native-uuid'
 import {
   cacheDirectory,
@@ -20,17 +19,17 @@ import RNFetchBlob from 'rn-fetch-blob'
 import {POST_IMG_MAX} from '#/lib/constants'
 import {logger} from '#/logger'
 import {isAndroid, isIOS} from '#/platform/detection'
-import {Dimensions} from './types'
+import {type PickerImage} from './picker.shared'
+import {type Dimensions} from './types'
 
 export async function compressIfNeeded(
-  img: Image,
+  img: PickerImage,
   maxSize: number = 1000000,
-): Promise<Image> {
-  const origUri = `file://${img.path}`
+): Promise<PickerImage> {
   if (img.size < maxSize) {
     return img
   }
-  const resizedImage = await doResize(origUri, {
+  const resizedImage = await doResize(normalizePath(img.path), {
     width: img.width,
     height: img.height,
     mode: 'stretch',
@@ -166,7 +165,10 @@ interface DoResizeOpts {
   maxSize: number
 }
 
-async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
+async function doResize(
+  localUri: string,
+  opts: DoResizeOpts,
+): Promise<PickerImage> {
   // We need to get the dimensions of the image before we resize it. Previously, the library we used allowed us to enter
   // a "max size", and it would do the "best possible size" calculation for us.
   // Now instead, we have to supply the final dimensions to the manipulation function instead.
@@ -181,6 +183,7 @@ async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
   let minQualityPercentage = 0
   let maxQualityPercentage = 101 // exclusive
   let newDataUri
+  const intermediateUris = []
 
   while (maxQualityPercentage - minQualityPercentage > 1) {
     const qualityPercentage = Math.round(
@@ -195,6 +198,8 @@ async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
       },
     )
 
+    intermediateUris.push(resizeRes.uri)
+
     const fileInfo = await getInfoAsync(resizeRes.uri)
     if (!fileInfo.exists) {
       throw new Error(
@@ -214,8 +219,12 @@ async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
     } else {
       maxQualityPercentage = qualityPercentage
     }
+  }
 
-    safeDeleteAsync(resizeRes.uri)
+  for (const intermediateUri of intermediateUris) {
+    if (newDataUri?.path !== normalizePath(intermediateUri)) {
+      safeDeleteAsync(intermediateUri)
+    }
   }
 
   if (newDataUri) {
diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts
index ffef7314d..ffcf0c533 100644
--- a/src/lib/media/manip.web.ts
+++ b/src/lib/media/manip.web.ts
@@ -1,12 +1,11 @@
-import {Image as RNImage} from 'react-native-image-crop-picker'
-
-import {Dimensions} from './types'
+import {type PickerImage} from './picker.shared'
+import {type Dimensions} from './types'
 import {blobToDataUri, getDataUriSize} from './util'
 
 export async function compressIfNeeded(
-  img: RNImage,
+  img: PickerImage,
   maxSize: number,
-): Promise<RNImage> {
+): Promise<PickerImage> {
   if (img.size < maxSize) {
     return img
   }
@@ -69,7 +68,10 @@ interface DoResizeOpts {
   maxSize: number
 }
 
-async function doResize(dataUri: string, opts: DoResizeOpts): Promise<RNImage> {
+async function doResize(
+  dataUri: string,
+  opts: DoResizeOpts,
+): Promise<PickerImage> {
   let newDataUri
 
   let minQualityPercentage = 0
diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx
index fc6fcde45..a2a9357ec 100644
--- a/src/lib/media/picker.e2e.tsx
+++ b/src/lib/media/picker.e2e.tsx
@@ -1,15 +1,12 @@
 import {
-  Image as RNImage,
-  openCropper as openCropperFn,
-} from 'react-native-image-crop-picker'
-import {
   documentDirectory,
   getInfoAsync,
   readDirectoryAsync,
 } from 'expo-file-system'
+import ExpoImageCropTool, {type OpenCropperOptions} from 'expo-image-crop-tool'
 
 import {compressIfNeeded} from './manip'
-import {CropperOptions} from './types'
+import {type PickerImage} from './picker.shared'
 
 async function getFile() {
   const imagesDir = documentDirectory!
@@ -37,18 +34,18 @@ async function getFile() {
   })
 }
 
-export async function openPicker(): Promise<RNImage[]> {
+export async function openPicker(): Promise<PickerImage[]> {
   return [await getFile()]
 }
 
-export async function openCamera(): Promise<RNImage> {
+export async function openCamera(): Promise<PickerImage> {
   return await getFile()
 }
 
-export async function openCropper(opts: CropperOptions) {
-  const item = await openCropperFn({
+export async function openCropper(opts: OpenCropperOptions) {
+  const item = await ExpoImageCropTool.openCropperAsync({
     ...opts,
-    forceJpg: true, // ios only
+    format: 'jpeg',
   })
 
   return {
diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts
index a45bf5c0f..21e680832 100644
--- a/src/lib/media/picker.shared.ts
+++ b/src/lib/media/picker.shared.ts
@@ -1,14 +1,21 @@
 import {
-  ImagePickerOptions,
+  type ImagePickerOptions,
   launchImageLibraryAsync,
   MediaTypeOptions,
 } from 'expo-image-picker'
-// TODO: replace global i18n instance with one returned from useLingui -sfn
 import {t} from '@lingui/macro'
 
 import * as Toast from '#/view/com/util/Toast'
 import {getDataUriSize} from './util'
 
+export type PickerImage = {
+  mime: string
+  height: number
+  width: number
+  path: string
+  size: number
+}
+
 export async function openPicker(opts?: ImagePickerOptions) {
   const response = await launchImageLibraryAsync({
     exif: false,
diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx
index 37e01e67f..6095730d5 100644
--- a/src/lib/media/picker.tsx
+++ b/src/lib/media/picker.tsx
@@ -1,36 +1,34 @@
-import {
-  Image as RNImage,
-  openCamera as openCameraFn,
-  openCropper as openCropperFn,
-} from 'react-native-image-crop-picker'
+import ExpoImageCropTool, {type OpenCropperOptions} from 'expo-image-crop-tool'
+import {type ImagePickerOptions, launchCameraAsync} from 'expo-image-picker'
 
-import {CameraOpts, CropperOptions} from './types'
-export {openPicker} from './picker.shared'
+export {openPicker, type PickerImage as RNImage} from './picker.shared'
 
-export async function openCamera(opts: CameraOpts): Promise<RNImage> {
-  const item = await openCameraFn({
-    width: opts.width,
-    height: opts.height,
-    freeStyleCropEnabled: opts.freeStyleCropEnabled,
-    cropperCircleOverlay: opts.cropperCircleOverlay,
-    cropping: false,
-    forceJpg: true, // ios only
-    compressImageQuality: 0.8,
-  })
+export async function openCamera(customOpts: ImagePickerOptions) {
+  const opts: ImagePickerOptions = {
+    mediaTypes: 'images',
+    ...customOpts,
+  }
+  const res = await launchCameraAsync(opts)
+
+  if (!res || !res.assets) {
+    throw new Error('Camera was closed before taking a photo')
+  }
+
+  const asset = res?.assets[0]
 
   return {
-    path: item.path,
-    mime: item.mime,
-    size: item.size,
-    width: item.width,
-    height: item.height,
+    path: asset.uri,
+    mime: asset.mimeType ?? 'image/jpeg',
+    size: asset.fileSize ?? 0,
+    width: asset.width,
+    height: asset.height,
   }
 }
 
-export async function openCropper(opts: CropperOptions) {
-  const item = await openCropperFn({
+export async function openCropper(opts: OpenCropperOptions) {
+  const item = await ExpoImageCropTool.openCropperAsync({
     ...opts,
-    forceJpg: true, // ios only
+    format: 'jpeg',
   })
 
   return {
diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx
index a53ffc961..b7d0d6f06 100644
--- a/src/lib/media/picker.web.tsx
+++ b/src/lib/media/picker.web.tsx
@@ -1,29 +1,29 @@
 /// <reference lib="dom" />
 
-import {Image as RNImage} from 'react-native-image-crop-picker'
+import {type OpenCropperOptions} from 'expo-image-crop-tool'
 
-import {CameraOpts, CropperOptions} from './types'
-export {openPicker} from './picker.shared'
 import {unstable__openModal} from '#/state/modals'
+import {type PickerImage} from './picker.shared'
+import {type CameraOpts} from './types'
 
-export async function openCamera(_opts: CameraOpts): Promise<RNImage> {
+export {openPicker, type PickerImage as RNImage} from './picker.shared'
+
+export async function openCamera(_opts: CameraOpts): Promise<PickerImage> {
   // const mediaType = opts.mediaType || 'photo' TODO
   throw new Error('TODO')
 }
 
-export async function openCropper(opts: CropperOptions): Promise<RNImage> {
+export async function openCropper(
+  opts: OpenCropperOptions,
+): Promise<PickerImage> {
   // TODO handle more opts
   return new Promise((resolve, reject) => {
     unstable__openModal({
       name: 'crop-image',
-      uri: opts.path,
-      dimensions:
-        opts.width && opts.height
-          ? {width: opts.width, height: opts.height}
-          : undefined,
-      aspect: opts.webAspectRatio,
-      circular: opts.webCircularCrop,
-      onSelect: (img?: RNImage) => {
+      uri: opts.imageUri,
+      aspect: opts.aspectRatio,
+      circular: opts.shape === 'circle',
+      onSelect: (img?: PickerImage) => {
         if (img) {
           resolve(img)
         } else {
diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts
index ec94256ea..c083093ac 100644
--- a/src/lib/media/types.ts
+++ b/src/lib/media/types.ts
@@ -1,5 +1,3 @@
-import {openCropper} from 'react-native-image-crop-picker'
-
 export interface Dimensions {
   width: number
   height: number
@@ -17,8 +15,3 @@ export interface CameraOpts {
   freeStyleCropEnabled?: boolean
   cropperCircleOverlay?: boolean
 }
-
-export type CropperOptions = Parameters<typeof openCropper>[0] & {
-  webAspectRatio?: number
-  webCircularCrop?: boolean
-}
diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx
index 73472ec33..0e738f145 100644
--- a/src/screens/Onboarding/StepProfile/index.tsx
+++ b/src/screens/Onboarding/StepProfile/index.tsx
@@ -182,11 +182,9 @@ export function StepProfile() {
 
     if (!isWeb) {
       image = await openCropper({
-        mediaType: 'photo',
-        cropperCircleOverlay: true,
-        height: 1000,
-        width: 1000,
-        path: image.path,
+        imageUri: image.path,
+        shape: 'circle',
+        aspectRatio: 1 / 1,
       })
     }
     image = await compressIfNeeded(image, 1000000)
diff --git a/src/screens/Profile/Header/EditProfileDialog.tsx b/src/screens/Profile/Header/EditProfileDialog.tsx
index a0e24d78a..8a9f0d540 100644
--- a/src/screens/Profile/Header/EditProfileDialog.tsx
+++ b/src/screens/Profile/Header/EditProfileDialog.tsx
@@ -1,12 +1,12 @@
 import {useCallback, useEffect, useState} from 'react'
 import {Dimensions, View} from 'react-native'
-import {type Image as RNImage} from 'react-native-image-crop-picker'
 import {type AppBskyActorDefs} from '@atproto/api'
 import {msg, Plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {urls} from '#/lib/constants'
 import {compressIfNeeded} from '#/lib/media/manip'
+import {type PickerImage} from '#/lib/media/picker.shared'
 import {cleanError} from '#/lib/strings/errors'
 import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
 import {logger} from '#/logger'
@@ -127,10 +127,10 @@ function DialogInner({
     profile.avatar,
   )
   const [newUserBanner, setNewUserBanner] = useState<
-    RNImage | undefined | null
+    PickerImage | undefined | null
   >()
   const [newUserAvatar, setNewUserAvatar] = useState<
-    RNImage | undefined | null
+    PickerImage | undefined | null
   >()
 
   const dirty =
@@ -144,7 +144,7 @@ function DialogInner({
   }, [dirty, setDirty])
 
   const onSelectNewAvatar = useCallback(
-    async (img: RNImage | null) => {
+    async (img: PickerImage | null) => {
       setImageError('')
       if (img === null) {
         setNewUserAvatar(null)
@@ -163,7 +163,7 @@ function DialogInner({
   )
 
   const onSelectNewBanner = useCallback(
-    async (img: RNImage | null) => {
+    async (img: PickerImage | null) => {
       setImageError('')
       if (!img) {
         setNewUserBanner(null)
diff --git a/src/state/gallery.ts b/src/state/gallery.ts
index f03ed2afe..73252dde7 100644
--- a/src/state/gallery.ts
+++ b/src/state/gallery.ts
@@ -16,7 +16,7 @@ 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'
+import {isNative} from '#/platform/detection'
 
 export type ImageTransformation = {
   crop?: ActionCrop['crop']
@@ -122,25 +122,13 @@ export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
     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} : {}),
+      imageUri: source.path,
     })
 
     return {
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index f79f6213f..3e738898a 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -1,8 +1,8 @@
 import React from 'react'
-import {type Image as RNImage} from 'react-native-image-crop-picker'
 import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api'
 
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {type PickerImage} from '#/lib/media/picker.shared'
 
 export interface EditProfileModal {
   name: 'edit-profile'
@@ -32,7 +32,7 @@ export interface CropImageModal {
   dimensions?: {width: number; height: number}
   aspect?: number
   circular?: boolean
-  onSelect: (img?: RNImage) => void
+  onSelect: (img?: PickerImage) => void
 }
 
 export interface DeleteAccountModal {
diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts
index 260a0bf2c..38e8cd62d 100644
--- a/src/state/queries/list.ts
+++ b/src/state/queries/list.ts
@@ -1,20 +1,20 @@
-import {Image as RNImage} from 'react-native-image-crop-picker'
 import {
-  $Typed,
-  AppBskyGraphDefs,
-  AppBskyGraphGetList,
-  AppBskyGraphList,
+  type $Typed,
+  type AppBskyGraphDefs,
+  type AppBskyGraphGetList,
+  type AppBskyGraphList,
   AtUri,
-  BskyAgent,
-  ComAtprotoRepoApplyWrites,
-  Facet,
-  Un$Typed,
+  type BskyAgent,
+  type ComAtprotoRepoApplyWrites,
+  type Facet,
+  type Un$Typed,
 } from '@atproto/api'
 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
 import chunk from 'lodash.chunk'
 
 import {uploadBlob} from '#/lib/api'
 import {until} from '#/lib/async/until'
+import {type PickerImage} from '#/lib/media/picker.shared'
 import {STALE} from '#/state/queries'
 import {useAgent, useSession} from '../session'
 import {invalidate as invalidateMyLists} from './my-lists'
@@ -47,7 +47,7 @@ export interface ListCreateMutateParams {
   name: string
   description: string
   descriptionFacets: Facet[] | undefined
-  avatar: RNImage | null | undefined
+  avatar: PickerImage | null | undefined
 }
 export function useListCreateMutation() {
   const {currentAccount} = useSession()
@@ -115,7 +115,7 @@ export interface ListMetadataMutateParams {
   name: string
   description: string
   descriptionFacets: Facet[] | undefined
-  avatar: RNImage | null | undefined
+  avatar: PickerImage | null | undefined
 }
 export function useListMetadataMutation() {
   const {currentAccount} = useSession()
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 609a62e25..9f40ab7f6 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -1,5 +1,4 @@
 import {useCallback} from 'react'
-import {type Image as RNImage} from 'react-native-image-crop-picker'
 import {
   type AppBskyActorDefs,
   type AppBskyActorGetProfile,
@@ -21,6 +20,7 @@ import {
 import {uploadBlob} from '#/lib/api'
 import {until} from '#/lib/async/until'
 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
+import {type PickerImage} from '#/lib/media/picker.shared'
 import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig'
 import {type Shadow} from '#/state/cache/types'
 import {STALE} from '#/state/queries'
@@ -131,8 +131,8 @@ interface ProfileUpdateParams {
     | ((
         existing: Un$Typed<AppBskyActorProfile.Record>,
       ) => Un$Typed<AppBskyActorProfile.Record>)
-  newUserAvatar?: RNImage | undefined | null
-  newUserBanner?: RNImage | undefined | null
+  newUserAvatar?: PickerImage | undefined | null
+  newUserBanner?: PickerImage | undefined | null
   checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean
 }
 export function useProfileUpdateMutation() {
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index fb3ab5c8f..1c9440eb1 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -35,9 +35,7 @@ export function OpenCameraBtn({disabled, onAdd}: Props) {
       }
 
       const img = await openCamera({
-        width: POST_IMG_MAX.width,
-        height: POST_IMG_MAX.height,
-        freeStyleCropEnabled: true,
+        aspect: [POST_IMG_MAX.width, POST_IMG_MAX.height],
       })
 
       // If we don't have permissions it's fine, we just wont save it. The post itself will still have access to
diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx
index 0e4e23b97..a7eae15dd 100644
--- a/src/view/com/modals/CreateOrEditList.tsx
+++ b/src/view/com/modals/CreateOrEditList.tsx
@@ -8,7 +8,6 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
-import {type Image as RNImage} from 'react-native-image-crop-picker'
 import {LinearGradient} from 'expo-linear-gradient'
 import {type AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
@@ -17,6 +16,7 @@ import {useLingui} from '@lingui/react'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {compressIfNeeded} from '#/lib/media/manip'
+import {type PickerImage} from '#/lib/media/picker.shared'
 import {cleanError, isNetworkError} from '#/lib/strings/errors'
 import {enforceLen} from '#/lib/strings/helpers'
 import {richTextToString} from '#/lib/strings/rich-text-helpers'
@@ -95,7 +95,7 @@ export function Component({
   const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
 
   const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
-  const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
+  const [newAvatar, setNewAvatar] = useState<PickerImage | undefined | null>()
 
   const onDescriptionChange = useCallback(
     (newText: string) => {
@@ -112,7 +112,7 @@ export function Component({
   }, [closeModal])
 
   const onSelectNewAvatar = useCallback(
-    async (img: RNImage | null) => {
+    async (img: PickerImage | null) => {
       if (!img) {
         setNewAvatar(null)
         setAvatar(undefined)
diff --git a/src/view/com/modals/CropImage.web.tsx b/src/view/com/modals/CropImage.web.tsx
index 41ca30657..78c0466f0 100644
--- a/src/view/com/modals/CropImage.web.tsx
+++ b/src/view/com/modals/CropImage.web.tsx
@@ -1,14 +1,14 @@
 import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {Image as RNImage} from 'react-native-image-crop-picker'
 import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
 import {LinearGradient} from 'expo-linear-gradient'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import ReactCrop, {PercentCrop} from 'react-image-crop'
+import ReactCrop, {type PercentCrop} from 'react-image-crop'
 
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {type PickerImage} from '#/lib/media/picker.shared'
 import {getDataUriSize} from '#/lib/media/util'
 import {gradients, s} from '#/lib/styles'
 import {useModalControls} from '#/state/modals'
@@ -25,7 +25,7 @@ export function Component({
   uri: string
   aspect?: number
   circular?: boolean
-  onSelect: (img?: RNImage) => void
+  onSelect: (img?: PickerImage) => void
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index ebc1281a3..cb1552fe5 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -8,7 +8,6 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
-import {type Image as RNImage} from 'react-native-image-crop-picker'
 import Animated, {FadeOut} from 'react-native-reanimated'
 import {LinearGradient} from 'expo-linear-gradient'
 import {type AppBskyActorDefs} from '@atproto/api'
@@ -18,6 +17,7 @@ import {useLingui} from '@lingui/react'
 import {MAX_DESCRIPTION, MAX_DISPLAY_NAME, urls} from '#/lib/constants'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {compressIfNeeded} from '#/lib/media/manip'
+import {type PickerImage} from '#/lib/media/picker.shared'
 import {cleanError} from '#/lib/strings/errors'
 import {enforceLen} from '#/lib/strings/helpers'
 import {colors, gradients, s} from '#/lib/styles'
@@ -67,16 +67,16 @@ export function Component({
     profile.avatar,
   )
   const [newUserBanner, setNewUserBanner] = useState<
-    RNImage | undefined | null
+    PickerImage | undefined | null
   >()
   const [newUserAvatar, setNewUserAvatar] = useState<
-    RNImage | undefined | null
+    PickerImage | undefined | null
   >()
   const onPressCancel = () => {
     closeModal()
   }
   const onSelectNewAvatar = useCallback(
-    async (img: RNImage | null) => {
+    async (img: PickerImage | null) => {
       setImageError('')
       if (img === null) {
         setNewUserAvatar(null)
@@ -95,7 +95,7 @@ export function Component({
   )
 
   const onSelectNewBanner = useCallback(
-    async (img: RNImage | null) => {
+    async (img: PickerImage | null) => {
       setImageError('')
       if (!img) {
         setNewUserBanner(null)
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 20fc1c65d..2450c111b 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -2,14 +2,13 @@ import React, {memo, useMemo} from 'react'
 import {
   Image,
   Pressable,
-  StyleProp,
+  type StyleProp,
   StyleSheet,
   View,
-  ViewStyle,
+  type ViewStyle,
 } from 'react-native'
-import {Image as RNImage} from 'react-native-image-crop-picker'
 import Svg, {Circle, Path, Rect} from 'react-native-svg'
-import {ModerationUI} from '@atproto/api'
+import {type ModerationUI} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -38,8 +37,13 @@ import {Link} from '#/components/Link'
 import {MediaInsetBorder} from '#/components/MediaInsetBorder'
 import * as Menu from '#/components/Menu'
 import {ProfileHoverCard} from '#/components/ProfileHoverCard'
-import * as bsky from '#/types/bsky'
-import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
+import type * as bsky from '#/types/bsky'
+import {
+  openCamera,
+  openCropper,
+  openPicker,
+  type RNImage,
+} from '../../../lib/media/picker'
 
 export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
 
@@ -312,9 +316,7 @@ let EditableUserAvatar = ({
 
     onSelectNewAvatar(
       await openCamera({
-        width: 1000,
-        height: 1000,
-        cropperCircleOverlay: true,
+        aspect: [1, 1],
       }),
     )
   }, [onSelectNewAvatar, requestCameraAccessIfNeeded])
@@ -336,15 +338,10 @@ let EditableUserAvatar = ({
 
     try {
       const croppedImage = await openCropper({
-        mediaType: 'photo',
-        cropperCircleOverlay: true,
-        height: 1000,
-        width: 1000,
-        path: item.path,
-        webAspectRatio: 1,
-        webCircularCrop: true,
+        imageUri: item.path,
+        shape: 'circle',
+        aspectRatio: 1,
       })
-
       onSelectNewAvatar(croppedImage)
     } catch (e: any) {
       // Don't log errors for cancelling selection to sentry on ios or android
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index e0ace5e48..ab7f25b80 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,8 +1,7 @@
 import React from 'react'
 import {Pressable, StyleSheet, View} from 'react-native'
-import {Image as RNImage} from 'react-native-image-crop-picker'
 import {Image} from 'expo-image'
-import {ModerationUI} from '@atproto/api'
+import {type ModerationUI} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -25,7 +24,12 @@ import {
 import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
 import * as Menu from '#/components/Menu'
-import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
+import {
+  openCamera,
+  openCropper,
+  openPicker,
+  type RNImage,
+} from '../../../lib/media/picker'
 
 export function UserBanner({
   type,
@@ -52,8 +56,7 @@ export function UserBanner({
     }
     onSelectNewBanner?.(
       await openCamera({
-        width: 3000,
-        height: 1000,
+        aspect: [3, 1],
       }),
     )
   }, [onSelectNewBanner, requestCameraAccessIfNeeded])
@@ -70,11 +73,8 @@ export function UserBanner({
     try {
       onSelectNewBanner?.(
         await openCropper({
-          mediaType: 'photo',
-          path: items[0].path,
-          width: 3000,
-          height: 1000,
-          webAspectRatio: 3,
+          imageUri: items[0].path,
+          aspectRatio: 3 / 1,
         }),
       )
     } catch (e: any) {