about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-01-27 15:51:24 -0600
committerPaul Frazee <pfrazee@gmail.com>2023-01-27 15:51:24 -0600
commit7916b26aadb7e003728d9dc653ab8b8deabf4076 (patch)
tree507d24512fd71c67d4fe49af4ae5f8746444cceb /src
parent0673129b2018c9db0f7c3fc3e2c3214150efcfb8 (diff)
downloadvoidsky-7916b26aadb7e003728d9dc653ab8b8deabf4076.tar.zst
Break out the web/native image picking code and make some progress on the web version
Diffstat (limited to 'src')
-rw-r--r--src/state/models/profile-view.ts2
-rw-r--r--src/state/models/shell-ui.ts16
-rw-r--r--src/state/models/user-local-photos.ts11
-rw-r--r--src/view/com/composer/ComposePost.tsx27
-rw-r--r--src/view/com/composer/photos/PhotoCarouselPicker.tsx (renamed from src/view/com/composer/PhotoCarouselPicker.tsx)68
-rw-r--r--src/view/com/composer/photos/PhotoCarouselPicker.web.tsx158
-rw-r--r--src/view/com/modals/EditProfile.tsx10
-rw-r--r--src/view/com/modals/Modal.web.tsx7
-rw-r--r--src/view/com/modals/crop-image/CropImage.tsx11
-rw-r--r--src/view/com/modals/crop-image/CropImage.web.tsx164
-rw-r--r--src/view/com/util/UserAvatar.tsx25
-rw-r--r--src/view/com/util/UserBanner.tsx39
-rw-r--r--src/view/com/util/images/ImageCropPicker.tsx6
-rw-r--r--src/view/com/util/images/ImageCropPicker.web.tsx32
-rw-r--r--src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx92
-rw-r--r--src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx75
-rw-r--r--src/view/com/util/images/image-crop-picker/types.ts31
-rw-r--r--src/view/lib/icons.tsx71
18 files changed, 711 insertions, 134 deletions
diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts
index 3228c57e8..79882a562 100644
--- a/src/state/models/profile-view.ts
+++ b/src/state/models/profile-view.ts
@@ -1,5 +1,5 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {Image as PickedImage} from '../../view/com/util/images/ImageCropPicker'
+import {Image as PickedImage} from '../../view/com/util/images/image-crop-picker/ImageCropPicker'
 import {
   AppBskyActorGetProfile as GetProfile,
   AppBskyActorProfile as Profile,
diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts
index b84d6ece9..09ffd265a 100644
--- a/src/state/models/shell-ui.ts
+++ b/src/state/models/shell-ui.ts
@@ -1,6 +1,7 @@
 import {makeAutoObservable} from 'mobx'
 import {ProfileViewModel} from './profile-view'
 import {isObj, hasProp} from '../lib/type-guards'
+import {PickedMedia} from '../../view/com/util/images/image-crop-picker/types'
 
 export class ConfirmModal {
   name = 'confirm'
@@ -52,6 +53,17 @@ export class ReportAccountModal {
   }
 }
 
+export class CropImageModal {
+  name = 'crop-image'
+
+  constructor(
+    public uri: string,
+    public onSelect: (img?: PickedMedia) => void,
+  ) {
+    makeAutoObservable(this)
+  }
+}
+
 interface LightboxModel {}
 
 export class ProfileImageLightbox implements LightboxModel {
@@ -98,6 +110,7 @@ export class ShellUiModel {
     | ServerInputModal
     | ReportPostModal
     | ReportAccountModal
+    | CropImageModal
     | undefined
   isLightboxActive = false
   activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
@@ -140,7 +153,8 @@ export class ShellUiModel {
       | EditProfileModal
       | ServerInputModal
       | ReportPostModal
-      | ReportAccountModal,
+      | ReportAccountModal
+      | CropImageModal,
   ) {
     this.isModalActive = true
     this.activeModal = modal
diff --git a/src/state/models/user-local-photos.ts b/src/state/models/user-local-photos.ts
index 08b2b3901..b14e8a6a4 100644
--- a/src/state/models/user-local-photos.ts
+++ b/src/state/models/user-local-photos.ts
@@ -16,14 +16,9 @@ export class UserLocalPhotosModel {
   }
 
   async setup() {
-    await this._getPhotos()
-  }
-
-  private async _getPhotos() {
-    CameraRoll.getPhotos({first: 20}).then(r => {
-      runInAction(() => {
-        this.photos = r.edges
-      })
+    const r = await CameraRoll.getPhotos({first: 20})
+    runInAction(() => {
+      this.photos = r.edges
     })
   }
 }
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx
index 2f30a1cf4..1144b5e48 100644
--- a/src/view/com/composer/ComposePost.tsx
+++ b/src/view/com/composer/ComposePost.tsx
@@ -37,8 +37,7 @@ import {
 } from '../../../lib/strings'
 import {getLinkMeta} from '../../../lib/link-meta'
 import {downloadAndResize} from '../../../lib/images'
-import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
-import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker'
+import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker'
 import {SelectedPhoto} from './SelectedPhoto'
 import {usePalette} from '../../lib/hooks/usePalette'
 
@@ -77,10 +76,6 @@ export const ComposePost = observer(function ComposePost({
     () => new UserAutocompleteViewModel(store),
     [store],
   )
-  const localPhotos = React.useMemo<UserLocalPhotosModel>(
-    () => new UserLocalPhotosModel(store),
-    [store],
-  )
 
   // HACK
   // there's a bug with @mattermost/react-native-paste-input where if the input
@@ -95,8 +90,7 @@ export const ComposePost = observer(function ComposePost({
   // initial setup
   useEffect(() => {
     autocompleteView.setup()
-    localPhotos.setup()
-  }, [autocompleteView, localPhotos])
+  }, [autocompleteView])
 
   // external link metadata-fetch flow
   useEffect(() => {
@@ -220,7 +214,7 @@ export const ComposePost = observer(function ComposePost({
     }
     const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
     if (imgUri) {
-      const finalImgPath = await cropPhoto(imgUri)
+      const finalImgPath = await cropPhoto(store, imgUri)
       onSelectPhotos([...selectedPhotos, finalImgPath])
     }
   }
@@ -412,15 +406,12 @@ export const ComposePost = observer(function ComposePost({
               />
             )}
           </ScrollView>
-          {isSelectingPhotos &&
-            localPhotos.photos != null &&
-            selectedPhotos.length < 4 && (
-              <PhotoCarouselPicker
-                selectedPhotos={selectedPhotos}
-                onSelectPhotos={onSelectPhotos}
-                localPhotos={localPhotos}
-              />
-            )}
+          {isSelectingPhotos && selectedPhotos.length < 4 && (
+            <PhotoCarouselPicker
+              selectedPhotos={selectedPhotos}
+              onSelectPhotos={onSelectPhotos}
+            />
+          )}
           <View style={[pal.border, styles.bottomBar]}>
             <TouchableOpacity
               testID="composerSelectPhotosButton"
diff --git a/src/view/com/composer/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx
index eb5b4dcf2..7a5c9f65d 100644
--- a/src/view/com/composer/PhotoCarouselPicker.tsx
+++ b/src/view/com/composer/photos/PhotoCarouselPicker.tsx
@@ -8,14 +8,14 @@ import {
   openPicker,
   openCamera,
   openCropper,
-} from '../util/images/ImageCropPicker'
+} from '../../util/images/image-crop-picker/ImageCropPicker'
 import {
   UserLocalPhotosModel,
   PhotoIdentifier,
-} from '../../../state/models/user-local-photos'
-import {compressIfNeeded, scaleDownDimensions} from '../../../lib/images'
-import {usePalette} from '../../lib/hooks/usePalette'
-import {useStores} from '../../../state'
+} from '../../../../state/models/user-local-photos'
+import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
+import {usePalette} from '../../../lib/hooks/usePalette'
+import {useStores, RootStoreModel} from '../../../../state'
 
 const MAX_WIDTH = 1000
 const MAX_HEIGHT = 1000
@@ -25,11 +25,10 @@ const IMAGE_PARAMS = {
   width: 1000,
   height: 1000,
   freeStyleCropEnabled: true,
-  forceJpg: true, // ios only
-  compressImageQuality: 1.0,
 }
 
 export async function cropPhoto(
+  store: RootStoreModel,
   path: string,
   imgWidth = MAX_WIDTH,
   imgHeight = MAX_HEIGHT,
@@ -40,10 +39,10 @@ export async function cropPhoto(
     {width: imgWidth, height: imgHeight},
     {width: MAX_WIDTH, height: MAX_HEIGHT},
   )
-  const cropperRes = await openCropper({
+  const cropperRes = await openCropper(store, {
     mediaType: 'photo',
     path,
-    ...IMAGE_PARAMS,
+    freeStyleCropEnabled: true,
     width,
     height,
   })
@@ -54,19 +53,30 @@ export async function cropPhoto(
 export const PhotoCarouselPicker = ({
   selectedPhotos,
   onSelectPhotos,
-  localPhotos,
 }: {
   selectedPhotos: string[]
   onSelectPhotos: (v: string[]) => void
-  localPhotos: UserLocalPhotosModel
 }) => {
   const pal = usePalette('default')
   const store = useStores()
+  const [localPhotos, setLocalPhotos] = React.useState<
+    UserLocalPhotosModel | undefined
+  >(undefined)
+
+  // initial setup
+  React.useEffect(() => {
+    const photos = new UserLocalPhotosModel(store)
+    photos.setup().then(() => {
+      if (photos.photos) {
+        setLocalPhotos(photos)
+      }
+    })
+  }, [store])
+
   const handleOpenCamera = useCallback(async () => {
     try {
-      const cameraRes = await openCamera({
+      const cameraRes = await openCamera(store, {
         mediaType: 'photo',
-        cropping: true,
         ...IMAGE_PARAMS,
       })
       const img = await compressIfNeeded(cameraRes, MAX_SIZE)
@@ -75,12 +85,13 @@ export const PhotoCarouselPicker = ({
       // ignore
       store.log.warn('Error using camera', err)
     }
-  }, [store.log, selectedPhotos, onSelectPhotos])
+  }, [store, selectedPhotos, onSelectPhotos])
 
   const handleSelectPhoto = useCallback(
     async (item: PhotoIdentifier) => {
       try {
         const imgPath = await cropPhoto(
+          store,
           item.node.image.uri,
           item.node.image.width,
           item.node.image.height,
@@ -91,11 +102,11 @@ export const PhotoCarouselPicker = ({
         store.log.warn('Error selecting photo', err)
       }
     },
-    [store.log, selectedPhotos, onSelectPhotos],
+    [store, selectedPhotos, onSelectPhotos],
   )
 
   const handleOpenGallery = useCallback(() => {
-    openPicker({
+    openPicker(store, {
       multiple: true,
       maxFiles: 4 - selectedPhotos.length,
       mediaType: 'photo',
@@ -109,10 +120,10 @@ export const PhotoCarouselPicker = ({
           {width: image.width, height: image.height},
           {width: MAX_WIDTH, height: MAX_HEIGHT},
         )
-        const cropperRes = await openCropper({
+        const cropperRes = await openCropper(store, {
           mediaType: 'photo',
           path: image.path,
-          ...IMAGE_PARAMS,
+          freeStyleCropEnabled: true,
           width,
           height,
         })
@@ -121,7 +132,7 @@ export const PhotoCarouselPicker = ({
       }
       onSelectPhotos([...selectedPhotos, ...result])
     })
-  }, [selectedPhotos, onSelectPhotos])
+  }, [store, selectedPhotos, onSelectPhotos])
 
   return (
     <ScrollView
@@ -150,15 +161,16 @@ export const PhotoCarouselPicker = ({
           size={24}
         />
       </TouchableOpacity>
-      {localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
-        <TouchableOpacity
-          testID="openSelectPhotoButton"
-          key={`local-image-${index}`}
-          style={[pal.border, styles.photoButton]}
-          onPress={() => handleSelectPhoto(item)}>
-          <Image style={styles.photo} source={{uri: item.node.image.uri}} />
-        </TouchableOpacity>
-      ))}
+      {localPhotos != null &&
+        localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
+          <TouchableOpacity
+            testID="openSelectPhotoButton"
+            key={`local-image-${index}`}
+            style={[pal.border, styles.photoButton]}
+            onPress={() => handleSelectPhoto(item)}>
+            <Image style={styles.photo} source={{uri: item.node.image.uri}} />
+          </TouchableOpacity>
+        ))}
     </ScrollView>
   )
 }
diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx
new file mode 100644
index 000000000..bb2800026
--- /dev/null
+++ b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx
@@ -0,0 +1,158 @@
+import React, {useCallback} from 'react'
+import {StyleSheet, TouchableOpacity, ScrollView} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {
+  openPicker,
+  openCamera,
+  openCropper,
+} from '../../util/images/image-crop-picker/ImageCropPicker'
+import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
+import {usePalette} from '../../../lib/hooks/usePalette'
+import {useStores, RootStoreModel} from '../../../../state'
+
+const MAX_WIDTH = 1000
+const MAX_HEIGHT = 1000
+const MAX_SIZE = 300000
+
+const IMAGE_PARAMS = {
+  width: 1000,
+  height: 1000,
+  freeStyleCropEnabled: true,
+}
+
+export async function cropPhoto(
+  store: RootStoreModel,
+  path: string,
+  imgWidth = MAX_WIDTH,
+  imgHeight = MAX_HEIGHT,
+) {
+  // choose target dimensions based on the original
+  // this causes the photo cropper to start with the full image "selected"
+  const {width, height} = scaleDownDimensions(
+    {width: imgWidth, height: imgHeight},
+    {width: MAX_WIDTH, height: MAX_HEIGHT},
+  )
+  const cropperRes = await openCropper(store, {
+    mediaType: 'photo',
+    path,
+    freeStyleCropEnabled: true,
+    width,
+    height,
+  })
+  const img = await compressIfNeeded(cropperRes, MAX_SIZE)
+  return img.path
+}
+
+export const PhotoCarouselPicker = ({
+  selectedPhotos,
+  onSelectPhotos,
+}: {
+  selectedPhotos: string[]
+  onSelectPhotos: (v: string[]) => void
+}) => {
+  const pal = usePalette('default')
+  const store = useStores()
+
+  const handleOpenCamera = useCallback(async () => {
+    try {
+      const cameraRes = await openCamera(store, {
+        mediaType: 'photo',
+        ...IMAGE_PARAMS,
+      })
+      const img = await compressIfNeeded(cameraRes, MAX_SIZE)
+      onSelectPhotos([...selectedPhotos, img.path])
+    } catch (err: any) {
+      // ignore
+      store.log.warn('Error using camera', err)
+    }
+  }, [store, selectedPhotos, onSelectPhotos])
+
+  const handleOpenGallery = useCallback(() => {
+    openPicker(store, {
+      multiple: true,
+      maxFiles: 4 - selectedPhotos.length,
+      mediaType: 'photo',
+    }).then(async items => {
+      const result = []
+
+      for (const image of items) {
+        // choose target dimensions based on the original
+        // this causes the photo cropper to start with the full image "selected"
+        const {width, height} = scaleDownDimensions(
+          {width: image.width, height: image.height},
+          {width: MAX_WIDTH, height: MAX_HEIGHT},
+        )
+        const cropperRes = await openCropper(store, {
+          mediaType: 'photo',
+          path: image.path,
+          freeStyleCropEnabled: true,
+          width,
+          height,
+        })
+        const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
+        result.push(finalImg.path)
+      }
+      onSelectPhotos([...selectedPhotos, ...result])
+    })
+  }, [store, selectedPhotos, onSelectPhotos])
+
+  return (
+    <ScrollView
+      testID="photoCarouselPickerView"
+      horizontal
+      style={[pal.view, styles.photosContainer]}
+      keyboardShouldPersistTaps="always"
+      showsHorizontalScrollIndicator={false}>
+      <TouchableOpacity
+        testID="openCameraButton"
+        style={[styles.galleryButton, pal.border, styles.photo]}
+        onPress={handleOpenCamera}>
+        <FontAwesomeIcon
+          icon="camera"
+          size={24}
+          style={pal.link as FontAwesomeIconStyle}
+        />
+      </TouchableOpacity>
+      <TouchableOpacity
+        testID="openGalleryButton"
+        style={[styles.galleryButton, pal.border, styles.photo]}
+        onPress={handleOpenGallery}>
+        <FontAwesomeIcon
+          icon="image"
+          style={pal.link as FontAwesomeIconStyle}
+          size={24}
+        />
+      </TouchableOpacity>
+    </ScrollView>
+  )
+}
+
+const styles = StyleSheet.create({
+  photosContainer: {
+    width: '100%',
+    maxHeight: 96,
+    padding: 8,
+    overflow: 'hidden',
+  },
+  galleryButton: {
+    borderWidth: 1,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  photoButton: {
+    width: 75,
+    height: 75,
+    marginRight: 8,
+    borderWidth: 1,
+    borderRadius: 16,
+  },
+  photo: {
+    width: 75,
+    height: 75,
+    marginRight: 8,
+    borderRadius: 16,
+  },
+})
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index 1c139e9bd..12b72a399 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 {Image as PickedImage} from '../util/images/ImageCropPicker'
+import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker'
 import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {useStores} from '../../../state'
@@ -48,12 +48,12 @@ export function Component({
   const [userAvatar, setUserAvatar] = useState<string | undefined>(
     profileView.avatar,
   )
-  const [newUserBanner, setNewUserBanner] = useState<PickedImage | undefined>()
-  const [newUserAvatar, setNewUserAvatar] = useState<PickedImage | undefined>()
+  const [newUserBanner, setNewUserBanner] = useState<PickedMedia | undefined>()
+  const [newUserAvatar, setNewUserAvatar] = useState<PickedMedia | undefined>()
   const onPressCancel = () => {
     store.shell.closeModal()
   }
-  const onSelectNewAvatar = async (img: PickedImage) => {
+  const onSelectNewAvatar = async (img: PickedMedia) => {
     try {
       const finalImg = await compressIfNeeded(img, 300000)
       setNewUserAvatar(finalImg)
@@ -62,7 +62,7 @@ export function Component({
       setError(e.message || e.toString())
     }
   }
-  const onSelectNewBanner = async (img: PickedImage) => {
+  const onSelectNewBanner = async (img: PickedMedia) => {
     try {
       const finalImg = await compressIfNeeded(img, 500000)
       setNewUserBanner(finalImg)
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 25493312d..44ea95f07 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -11,6 +11,7 @@ import * as EditProfileModal from './EditProfile'
 import * as ServerInputModal from './ServerInput'
 import * as ReportPostModal from './ReportPost'
 import * as ReportAccountModal from './ReportAccount'
+import * as CropImageModal from './crop-image/CropImage.web'
 
 export const Modal = observer(function Modal() {
   const store = useStores()
@@ -50,6 +51,12 @@ export const Modal = observer(function Modal() {
     element = <ReportPostModal.Component />
   } else if (store.shell.activeModal?.name === 'report-account') {
     element = <ReportAccountModal.Component />
+  } else if (store.shell.activeModal?.name === 'crop-image') {
+    element = (
+      <CropImageModal.Component
+        {...(store.shell.activeModal as models.CropImageModal)}
+      />
+    )
   } else {
     return null
   }
diff --git a/src/view/com/modals/crop-image/CropImage.tsx b/src/view/com/modals/crop-image/CropImage.tsx
new file mode 100644
index 000000000..9ac3f277f
--- /dev/null
+++ b/src/view/com/modals/crop-image/CropImage.tsx
@@ -0,0 +1,11 @@
+/**
+ * NOTE
+ * This modal is used only in the web build
+ * Native uses a third-party library
+ */
+
+export const snapPoints = ['0%']
+
+export function Component() {
+  return null
+}
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
new file mode 100644
index 000000000..1f234c4a6
--- /dev/null
+++ b/src/view/com/modals/crop-image/CropImage.web.tsx
@@ -0,0 +1,164 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import ImageEditor from 'react-avatar-editor'
+import {Slider} from '@miblanchard/react-native-slider'
+import LinearGradient from 'react-native-linear-gradient'
+import {Text} from '../../util/text/Text'
+import {PickedMedia} from '../../util/images/image-crop-picker/types'
+import {s, gradients} from '../../../lib/styles'
+import {useStores} from '../../../../state'
+import {usePalette} from '../../../lib/hooks/usePalette'
+import {SquareIcon, RectWideIcon, RectTallIcon} from '../../../lib/icons'
+
+enum AspectRatio {
+  Square = 'square',
+  Wide = 'wide',
+  Tall = 'tall',
+}
+interface Dim {
+  width: number
+  height: number
+}
+const DIMS: Record<string, Dim> = {
+  [AspectRatio.Square]: {width: 1000, height: 1000},
+  [AspectRatio.Wide]: {width: 1000, height: 750},
+  [AspectRatio.Tall]: {width: 750, height: 1000},
+}
+
+export const snapPoints = ['0%']
+
+export function Component({
+  uri,
+  onSelect,
+}: {
+  uri: string
+  onSelect: (img?: PickedMedia) => void
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square)
+  const [scale, setScale] = React.useState<number>(1)
+
+  const doSetAs = (v: AspectRatio) => () => setAs(v)
+
+  const onPressCancel = () => {
+    onSelect(undefined)
+    store.shell.closeModal()
+  }
+  const onPressDone = () => {
+    console.log('TODO')
+    onSelect(undefined) // TODO
+    store.shell.closeModal()
+  }
+
+  let cropperStyle
+  if (as === AspectRatio.Square) {
+    cropperStyle = styles.cropperSquare
+  } else if (as === AspectRatio.Wide) {
+    cropperStyle = styles.cropperWide
+  } else if (as === AspectRatio.Tall) {
+    cropperStyle = styles.cropperTall
+  }
+  return (
+    <View>
+      <View style={[styles.cropper, cropperStyle]}>
+        <ImageEditor
+          style={styles.imageEditor}
+          image={uri}
+          width={DIMS[as].width}
+          height={DIMS[as].height}
+          scale={scale}
+        />
+      </View>
+      <View style={styles.ctrls}>
+        <Slider
+          value={scale}
+          onValueChange={(v: number | number[]) =>
+            setScale(Array.isArray(v) ? v[0] : v)
+          }
+          minimumValue={1}
+          maximumValue={3}
+          containerStyle={styles.slider}
+        />
+        <TouchableOpacity onPress={doSetAs(AspectRatio.Wide)}>
+          <RectWideIcon
+            size={24}
+            style={as === AspectRatio.Wide ? s.blue3 : undefined}
+          />
+        </TouchableOpacity>
+        <TouchableOpacity onPress={doSetAs(AspectRatio.Tall)}>
+          <RectTallIcon
+            size={24}
+            style={as === AspectRatio.Tall ? s.blue3 : undefined}
+          />
+        </TouchableOpacity>
+        <TouchableOpacity onPress={doSetAs(AspectRatio.Square)}>
+          <SquareIcon
+            size={24}
+            style={as === AspectRatio.Square ? s.blue3 : undefined}
+          />
+        </TouchableOpacity>
+      </View>
+      <View style={styles.btns}>
+        <TouchableOpacity onPress={onPressCancel}>
+          <Text type="xl" style={pal.link}>
+            Cancel
+          </Text>
+        </TouchableOpacity>
+        <View style={s.flex1} />
+        <TouchableOpacity onPress={onPressDone}>
+          <LinearGradient
+            colors={[gradients.blueLight.start, gradients.blueLight.end]}
+            start={{x: 0, y: 0}}
+            end={{x: 1, y: 1}}
+            style={[styles.btn]}>
+            <Text type="xl-medium" style={s.white}>
+              Done
+            </Text>
+          </LinearGradient>
+        </TouchableOpacity>
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  cropper: {
+    marginLeft: 'auto',
+    marginRight: 'auto',
+  },
+  cropperSquare: {
+    width: 400,
+    height: 400,
+  },
+  cropperWide: {
+    width: 400,
+    height: 300,
+  },
+  cropperTall: {
+    width: 300,
+    height: 400,
+  },
+  imageEditor: {
+    maxWidth: '100%',
+  },
+  ctrls: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginTop: 10,
+  },
+  slider: {
+    flex: 1,
+    marginRight: 10,
+  },
+  btns: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginTop: 10,
+  },
+  btn: {
+    borderRadius: 4,
+    paddingVertical: 8,
+    paddingHorizontal: 24,
+  },
+})
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index d91607b6c..287d94412 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -6,8 +6,9 @@ import {
   openCamera,
   openCropper,
   openPicker,
-  Image as PickedImage,
-} from './images/ImageCropPicker'
+  PickedMedia,
+} from './images/image-crop-picker/ImageCropPicker'
+import {useStores} from '../../../state'
 import {colors, gradients} from '../../lib/styles'
 
 export function UserAvatar({
@@ -21,8 +22,9 @@ export function UserAvatar({
   handle: string
   displayName: string | undefined
   avatar?: string | null
-  onSelectNewAvatar?: (img: PickedImage) => void
+  onSelectNewAvatar?: (img: PickedMedia) => void
 }) {
+  const store = useStores()
   const initials = getInitials(displayName || handle)
 
   const handleEditAvatar = useCallback(() => {
@@ -30,37 +32,32 @@ export function UserAvatar({
       {
         text: 'Take a new photo',
         onPress: () => {
-          openCamera({
+          openCamera(store, {
             mediaType: 'photo',
-            cropping: true,
             width: 1000,
             height: 1000,
             cropperCircleOverlay: true,
-            forceJpg: true, // ios only
-            compressImageQuality: 1,
           }).then(onSelectNewAvatar)
         },
       },
       {
         text: 'Select from gallery',
         onPress: () => {
-          openPicker({
+          openPicker(store, {
             mediaType: 'photo',
-          }).then(async item => {
-            await openCropper({
+          }).then(async items => {
+            await openCropper(store, {
               mediaType: 'photo',
-              path: item.path,
+              path: items[0].path,
               width: 1000,
               height: 1000,
               cropperCircleOverlay: true,
-              forceJpg: true, // ios only
-              compressImageQuality: 1,
             }).then(onSelectNewAvatar)
           })
         },
       },
     ])
-  }, [onSelectNewAvatar])
+  }, [store, onSelectNewAvatar])
 
   const renderSvg = (svgSize: number, svgInitials: string) => (
     <Svg width={svgSize} height={svgSize} viewBox="0 0 100 100">
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index fe606bc55..d5d6e3aaa 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -2,57 +2,56 @@ import React, {useCallback} from 'react'
 import {StyleSheet, View, TouchableOpacity, Alert, Image} from 'react-native'
 import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Image as PickedImage} from './images/ImageCropPicker'
 import {colors, gradients} from '../../lib/styles'
-import {openCamera, openCropper, openPicker} from './images/ImageCropPicker'
+import {
+  openCamera,
+  openCropper,
+  openPicker,
+  PickedMedia,
+} from './images/image-crop-picker/ImageCropPicker'
+import {useStores} from '../../../state'
 
 export function UserBanner({
   banner,
   onSelectNewBanner,
 }: {
   banner?: string | null
-  onSelectNewBanner?: (img: PickedImage) => void
+  onSelectNewBanner?: (img: PickedMedia) => void
 }) {
+  const store = useStores()
   const handleEditBanner = useCallback(() => {
     Alert.alert('Select upload method', '', [
       {
         text: 'Take a new photo',
         onPress: () => {
-          openCamera({
+          openCamera(store, {
             mediaType: 'photo',
-            cropping: true,
-            compressImageMaxWidth: 3000,
+            // compressImageMaxWidth: 3000, TODO needed?
             width: 3000,
-            compressImageMaxHeight: 1000,
+            // compressImageMaxHeight: 1000, TODO needed?
             height: 1000,
-            forceJpg: true, // ios only
-            compressImageQuality: 1,
-            includeExif: true,
           }).then(onSelectNewBanner)
         },
       },
       {
         text: 'Select from gallery',
         onPress: () => {
-          openPicker({
+          openPicker(store, {
             mediaType: 'photo',
-          }).then(async item => {
-            await openCropper({
+          }).then(async items => {
+            await openCropper(store, {
               mediaType: 'photo',
-              path: item.path,
-              compressImageMaxWidth: 3000,
+              path: items[0].path,
+              // compressImageMaxWidth: 3000, TODO needed?
               width: 3000,
-              compressImageMaxHeight: 1000,
+              // compressImageMaxHeight: 1000, TODO needed?
               height: 1000,
-              forceJpg: true, // ios only
-              compressImageQuality: 1,
-              includeExif: true,
             }).then(onSelectNewBanner)
           })
         },
       },
     ])
-  }, [onSelectNewBanner])
+  }, [store, onSelectNewBanner])
 
   const renderSvg = () => (
     <Svg width="100%" height="150" viewBox="50 0 200 100">
diff --git a/src/view/com/util/images/ImageCropPicker.tsx b/src/view/com/util/images/ImageCropPicker.tsx
deleted file mode 100644
index 9cd4da9f5..000000000
--- a/src/view/com/util/images/ImageCropPicker.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-export {
-  openPicker,
-  openCamera,
-  openCropper,
-} from 'react-native-image-crop-picker'
-export type {Image} from 'react-native-image-crop-picker'
diff --git a/src/view/com/util/images/ImageCropPicker.web.tsx b/src/view/com/util/images/ImageCropPicker.web.tsx
deleted file mode 100644
index a385e2e93..000000000
--- a/src/view/com/util/images/ImageCropPicker.web.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import type {
-  Image,
-  Video,
-  ImageOrVideo,
-  Options,
-  PossibleArray,
-} from 'react-native-image-crop-picker'
-
-export type {Image} from 'react-native-image-crop-picker'
-
-type MediaType<O> = O extends {mediaType: 'photo'}
-  ? Image
-  : O extends {mediaType: 'video'}
-  ? Video
-  : ImageOrVideo
-
-export async function openPicker<O extends Options>(
-  _options: O,
-): Promise<PossibleArray<O, MediaType<O>>> {
-  // TODO
-  throw new Error('TODO')
-}
-export async function openCamera<O extends Options>(
-  _options: O,
-): Promise<PossibleArray<O, MediaType<O>>> {
-  // TODO
-  throw new Error('TODO')
-}
-export async function openCropper(_options: Options): Promise<Image> {
-  // TODO
-  throw new Error('TODO')
-}
diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx
new file mode 100644
index 000000000..ddc9e87fd
--- /dev/null
+++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx
@@ -0,0 +1,92 @@
+import {
+  openPicker as openPickerFn,
+  openCamera as openCameraFn,
+  openCropper as openCropperFn,
+  ImageOrVideo,
+} from 'react-native-image-crop-picker'
+import {RootStoreModel} from '../../../../../state'
+import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
+export type {PickedMedia} from './types'
+
+/**
+ * NOTE
+ * These methods all include the RootStoreModel as the first param
+ * because the web versions require it. The signatures have to remain
+ * equivalent between the different forms, but the store param is not
+ * used here.
+ * -prf
+ */
+
+export async function openPicker(
+  _store: RootStoreModel,
+  opts: PickerOpts,
+): Promise<PickedMedia[]> {
+  const mediaType = opts.mediaType || 'photo'
+  const items = await openPickerFn({
+    mediaType,
+    multiple: opts.multiple,
+    maxFiles: opts.maxFiles,
+  })
+  const toMedia = (item: ImageOrVideo) => ({
+    mediaType,
+    path: item.path,
+    mime: item.mime,
+    size: item.size,
+    width: item.width,
+    height: item.height,
+  })
+  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 openCameraFn({
+    mediaType,
+    width: opts.width,
+    height: opts.height,
+    freeStyleCropEnabled: opts.freeStyleCropEnabled,
+    cropperCircleOverlay: opts.cropperCircleOverlay,
+    cropping: true,
+    forceJpg: true, // ios only
+    compressImageQuality: 1.0,
+  })
+  return {
+    mediaType,
+    path: item.path,
+    mime: item.mime,
+    size: item.size,
+    width: item.width,
+    height: item.height,
+  }
+}
+
+export async function openCropper(
+  _store: RootStoreModel,
+  opts: CropperOpts,
+): Promise<PickedMedia> {
+  const mediaType = opts.mediaType || 'photo'
+  const item = await openCropperFn({
+    path: opts.path,
+    mediaType: opts.mediaType || 'photo',
+    width: opts.width,
+    height: opts.height,
+    freeStyleCropEnabled: opts.freeStyleCropEnabled,
+    cropperCircleOverlay: opts.cropperCircleOverlay,
+    forceJpg: true, // ios only
+    compressImageQuality: 1.0,
+  })
+  return {
+    mediaType,
+    path: item.path,
+    mime: item.mime,
+    size: item.size,
+    width: item.width,
+    height: item.height,
+  }
+}
diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx
new file mode 100644
index 000000000..a7037f3a4
--- /dev/null
+++ b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx
@@ -0,0 +1,75 @@
+/// <reference lib="dom" />
+
+import {CropImageModal} from '../../../../../state/models/shell-ui'
+import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
+export type {PickedMedia} from './types'
+import {RootStoreModel} from '../../../../../state'
+
+interface PickedFile {
+  uri: string
+  path: string
+  size: number
+}
+
+export async function openPicker(
+  store: RootStoreModel,
+  opts: PickerOpts,
+): Promise<PickedMedia[] | PickedMedia> {
+  const res = await selectFile(opts)
+  return new Promise((resolve, reject) => {
+    store.shell.openModal(
+      new CropImageModal(res.uri, (img?: PickedMedia) => {
+        if (img) {
+          resolve(img)
+        } else {
+          reject(new Error('Canceled'))
+        }
+      }),
+    )
+  })
+}
+
+export async function openCamera(
+  _store: RootStoreModel,
+  opts: CameraOpts,
+): Promise<PickedMedia> {
+  const mediaType = opts.mediaType || 'photo'
+  throw new Error('TODO')
+}
+
+export async function openCropper(
+  _store: RootStoreModel,
+  opts: CropperOpts,
+): Promise<PickedMedia> {
+  const mediaType = opts.mediaType || 'photo'
+  throw new Error('TODO')
+}
+
+function selectFile(opts: PickerOpts): Promise<PickedFile> {
+  return new Promise((resolve, reject) => {
+    var input = document.createElement('input')
+    input.type = 'file'
+    input.accept = opts.mediaType === 'photo' ? 'image/*' : '*/*'
+    input.onchange = e => {
+      const target = e.target as HTMLInputElement
+      const file = target?.files?.[0]
+      if (!file) {
+        return reject(new Error('Canceled'))
+      }
+
+      var reader = new FileReader()
+      reader.readAsDataURL(file)
+      reader.onload = readerEvent => {
+        if (!readerEvent.target) {
+          return reject(new Error('Canceled'))
+        }
+        resolve({
+          uri: readerEvent.target.result as string,
+          path: file.name,
+          size: file.size,
+        })
+      }
+    }
+    input.click()
+  })
+}
diff --git a/src/view/com/util/images/image-crop-picker/types.ts b/src/view/com/util/images/image-crop-picker/types.ts
new file mode 100644
index 000000000..3197b4d3e
--- /dev/null
+++ b/src/view/com/util/images/image-crop-picker/types.ts
@@ -0,0 +1,31 @@
+export interface PickerOpts {
+  mediaType?: 'photo'
+  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
+}
diff --git a/src/view/lib/icons.tsx b/src/view/lib/icons.tsx
index 23a8e29dd..f400c3f72 100644
--- a/src/view/lib/icons.tsx
+++ b/src/view/lib/icons.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {StyleProp, TextStyle, ViewStyle} from 'react-native'
-import Svg, {Path} from 'react-native-svg'
+import Svg, {Path, Rect} from 'react-native-svg'
 
 export function GridIcon({
   style,
@@ -458,3 +458,72 @@ export function CommentBottomArrow({
     </Svg>
   )
 }
+
+export function SquareIcon({
+  style,
+  size,
+  strokeWidth = 1.3,
+}: {
+  style?: StyleProp<TextStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      fill="none"
+      viewBox="0 0 24 24"
+      strokeWidth={strokeWidth || 1}
+      stroke="currentColor"
+      width={size || 24}
+      height={size || 24}
+      style={style}>
+      <Rect x="6" y="6" width="12" height="12" strokeLinejoin="round" />
+    </Svg>
+  )
+}
+
+export function RectWideIcon({
+  style,
+  size,
+  strokeWidth = 1.3,
+}: {
+  style?: StyleProp<TextStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      fill="none"
+      viewBox="0 0 24 24"
+      strokeWidth={strokeWidth || 1}
+      stroke="currentColor"
+      width={size || 24}
+      height={size || 24}
+      style={style}>
+      <Rect x="4" y="6" width="16" height="12" strokeLinejoin="round" />
+    </Svg>
+  )
+}
+
+export function RectTallIcon({
+  style,
+  size,
+  strokeWidth = 1.3,
+}: {
+  style?: StyleProp<TextStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      fill="none"
+      viewBox="0 0 24 24"
+      strokeWidth={strokeWidth || 1}
+      stroke="currentColor"
+      width={size || 24}
+      height={size || 24}
+      style={style}>
+      <Rect x="6" y="4" width="12" height="16" strokeLinejoin="round" />
+    </Svg>
+  )
+}