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/picker.web.tsx4
-rw-r--r--src/lib/media/types.ts5
-rw-r--r--src/state/modals/index.tsx2
-rw-r--r--src/view/com/composer/photos/EditImageDialog.tsx14
-rw-r--r--src/view/com/composer/photos/EditImageDialog.web.tsx105
-rw-r--r--src/view/com/composer/photos/Gallery.tsx34
-rw-r--r--src/view/com/modals/CropImage.web.tsx145
-rw-r--r--src/view/com/modals/Modal.web.tsx2
-rw-r--r--src/view/com/modals/crop-image/CropImage.web.tsx228
-rw-r--r--src/view/com/modals/crop-image/cropImageUtil.ts13
-rw-r--r--src/view/com/util/UserAvatar.tsx18
-rw-r--r--src/view/com/util/UserBanner.tsx15
12 files changed, 311 insertions, 274 deletions
diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx
index 8782e1457..a53ffc961 100644
--- a/src/lib/media/picker.web.tsx
+++ b/src/lib/media/picker.web.tsx
@@ -18,9 +18,11 @@ export async function openCropper(opts: CropperOptions): Promise<RNImage> {
       name: 'crop-image',
       uri: opts.path,
       dimensions:
-        opts.height && opts.width
+        opts.width && opts.height
           ? {width: opts.width, height: opts.height}
           : undefined,
+      aspect: opts.webAspectRatio,
+      circular: opts.webCircularCrop,
       onSelect: (img?: RNImage) => {
         if (img) {
           resolve(img)
diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts
index e6f442759..ec94256ea 100644
--- a/src/lib/media/types.ts
+++ b/src/lib/media/types.ts
@@ -18,4 +18,7 @@ export interface CameraOpts {
   cropperCircleOverlay?: boolean
 }
 
-export type CropperOptions = Parameters<typeof openCropper>[0]
+export type CropperOptions = Parameters<typeof openCropper>[0] & {
+  webAspectRatio?: number
+  webCircularCrop?: boolean
+}
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 9bc96cf5e..5be21dfd3 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -39,6 +39,8 @@ export interface CropImageModal {
   name: 'crop-image'
   uri: string
   dimensions?: {width: number; height: number}
+  aspect?: number
+  circular?: boolean
   onSelect: (img?: RNImage) => void
 }
 
diff --git a/src/view/com/composer/photos/EditImageDialog.tsx b/src/view/com/composer/photos/EditImageDialog.tsx
new file mode 100644
index 000000000..4263587fd
--- /dev/null
+++ b/src/view/com/composer/photos/EditImageDialog.tsx
@@ -0,0 +1,14 @@
+import React from 'react'
+
+import {ComposerImage} from '#/state/gallery'
+import * as Dialog from '#/components/Dialog'
+
+export type EditImageDialogProps = {
+  control: Dialog.DialogOuterProps['control']
+  image: ComposerImage
+  onChange: (next: ComposerImage) => void
+}
+
+export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
+  return null
+}
diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx
new file mode 100644
index 000000000..0afb83ed9
--- /dev/null
+++ b/src/view/com/composer/photos/EditImageDialog.web.tsx
@@ -0,0 +1,105 @@
+import 'react-image-crop/dist/ReactCrop.css'
+
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import ReactCrop, {PercentCrop} from 'react-image-crop'
+
+import {
+  ImageSource,
+  ImageTransformation,
+  manipulateImage,
+} from '#/state/gallery'
+import {atoms as a} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Text} from '#/components/Typography'
+import {EditImageDialogProps} from './EditImageDialog'
+
+export const EditImageDialog = (props: EditImageDialogProps) => {
+  return (
+    <Dialog.Outer control={props.control}>
+      <EditImageInner key={props.image.source.id} {...props} />
+    </Dialog.Outer>
+  )
+}
+
+const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
+  const {_} = useLingui()
+
+  const source = image.source
+
+  const initialCrop = getInitialCrop(source, image.manips)
+  const [crop, setCrop] = React.useState(initialCrop)
+
+  const isEmpty = !crop || (crop.width || crop.height) === 0
+  const isNew = initialCrop ? true : !isEmpty
+
+  const onPressSubmit = React.useCallback(async () => {
+    const result = await manipulateImage(image, {
+      crop:
+        crop && (crop.width || crop.height) !== 0
+          ? {
+              originX: (crop.x * source.width) / 100,
+              originY: (crop.y * source.height) / 100,
+              width: (crop.width * source.width) / 100,
+              height: (crop.height * source.height) / 100,
+            }
+          : undefined,
+    })
+
+    onChange(result)
+    control.close()
+  }, [crop, image, source, control, onChange])
+
+  return (
+    <Dialog.Inner label={_(msg`Edit image`)}>
+      <Dialog.Close />
+
+      <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
+        <Trans>Edit image</Trans>
+      </Text>
+
+      <View style={[a.align_center]}>
+        <ReactCrop
+          crop={crop}
+          onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
+          className="ReactCrop--no-animate">
+          <img src={source.path} style={{maxHeight: `50vh`}} />
+        </ReactCrop>
+      </View>
+
+      <View style={[a.mt_md, a.gap_md]}>
+        <Button
+          disabled={!isNew}
+          label={_(msg`Save`)}
+          size="large"
+          color="primary"
+          variant="solid"
+          onPress={onPressSubmit}>
+          <ButtonText>
+            <Trans>Save</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </Dialog.Inner>
+  )
+}
+
+const getInitialCrop = (
+  source: ImageSource,
+  manips: ImageTransformation | undefined,
+): PercentCrop | undefined => {
+  const initialArea = manips?.crop
+
+  if (initialArea) {
+    return {
+      unit: '%',
+      x: (initialArea.originX / source.width) * 100,
+      y: (initialArea.originY / source.height) * 100,
+      width: (initialArea.width / source.width) * 100,
+      height: (initialArea.height / source.height) * 100,
+    }
+  }
+}
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 83c1e3c80..369f08d74 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -21,6 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery'
 import {Text} from '#/view/com/util/text/Text'
 import {useTheme} from '#/alf'
 import * as Dialog from '#/components/Dialog'
+import {EditImageDialog} from './EditImageDialog'
 import {ImageAltTextDialog} from './ImageAltTextDialog'
 
 const IMAGE_GAP = 8
@@ -144,12 +145,15 @@ const GalleryItem = ({
   const t = useTheme()
 
   const altTextControl = Dialog.useDialogControl()
+  const editControl = Dialog.useDialogControl()
 
   const onImageEdit = () => {
     if (isNative) {
       cropImage(image).then(next => {
         onChange(next)
       })
+    } else {
+      editControl.open()
     }
   }
 
@@ -185,21 +189,15 @@ const GalleryItem = ({
         </Text>
       </TouchableOpacity>
       <View style={imageControlsStyle}>
-        {isNative && (
-          <TouchableOpacity
-            testID="editPhotoButton"
-            accessibilityRole="button"
-            accessibilityLabel={_(msg`Edit image`)}
-            accessibilityHint=""
-            onPress={onImageEdit}
-            style={styles.imageControl}>
-            <FontAwesomeIcon
-              icon="pen"
-              size={12}
-              style={{color: colors.white}}
-            />
-          </TouchableOpacity>
-        )}
+        <TouchableOpacity
+          testID="editPhotoButton"
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Edit image`)}
+          accessibilityHint=""
+          onPress={onImageEdit}
+          style={styles.imageControl}>
+          <FontAwesomeIcon icon="pen" size={12} style={{color: colors.white}} />
+        </TouchableOpacity>
         <TouchableOpacity
           testID="removePhotoButton"
           accessibilityRole="button"
@@ -237,6 +235,12 @@ const GalleryItem = ({
         image={image}
         onChange={onChange}
       />
+
+      <EditImageDialog
+        control={editControl}
+        image={image}
+        onChange={onChange}
+      />
     </View>
   )
 }
diff --git a/src/view/com/modals/CropImage.web.tsx b/src/view/com/modals/CropImage.web.tsx
new file mode 100644
index 000000000..41ca30657
--- /dev/null
+++ b/src/view/com/modals/CropImage.web.tsx
@@ -0,0 +1,145 @@
+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 {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {getDataUriSize} from '#/lib/media/util'
+import {gradients, s} from '#/lib/styles'
+import {useModalControls} from '#/state/modals'
+import {Text} from '#/view/com/util/text/Text'
+
+export const snapPoints = ['0%']
+
+export function Component({
+  uri,
+  aspect,
+  circular,
+  onSelect,
+}: {
+  uri: string
+  aspect?: number
+  circular?: boolean
+  onSelect: (img?: RNImage) => void
+}) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+
+  const {closeModal} = useModalControls()
+  const {isMobile} = useWebMediaQueries()
+
+  const imageRef = React.useRef<HTMLImageElement>(null)
+  const [crop, setCrop] = React.useState<PercentCrop>()
+
+  const isEmpty = !crop || (crop.width || crop.height) === 0
+
+  const onPressCancel = () => {
+    onSelect(undefined)
+    closeModal()
+  }
+  const onPressDone = async () => {
+    const img = imageRef.current!
+
+    const result = await manipulateAsync(
+      uri,
+      isEmpty
+        ? []
+        : [
+            {
+              crop: {
+                originX: (crop.x * img.naturalWidth) / 100,
+                originY: (crop.y * img.naturalHeight) / 100,
+                width: (crop.width * img.naturalWidth) / 100,
+                height: (crop.height * img.naturalHeight) / 100,
+              },
+            },
+          ],
+      {
+        base64: true,
+        format: SaveFormat.JPEG,
+      },
+    )
+
+    onSelect({
+      path: result.uri,
+      mime: 'image/jpeg',
+      size: result.base64 !== undefined ? getDataUriSize(result.base64) : 0,
+      width: result.width,
+      height: result.height,
+    })
+
+    closeModal()
+  }
+
+  return (
+    <View>
+      <View style={[styles.cropper, pal.borderDark]}>
+        <ReactCrop
+          aspect={aspect}
+          crop={crop}
+          onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
+          circularCrop={circular}>
+          <img ref={imageRef} src={uri} style={{maxHeight: '75vh'}} />
+        </ReactCrop>
+      </View>
+      <View style={[styles.btns, isMobile && {paddingHorizontal: 16}]}>
+        <TouchableOpacity
+          onPress={onPressCancel}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Cancel image crop`)}
+          accessibilityHint={_(msg`Exits image cropping process`)}>
+          <Text type="xl" style={pal.link}>
+            <Trans>Cancel</Trans>
+          </Text>
+        </TouchableOpacity>
+        <View style={s.flex1} />
+        <TouchableOpacity
+          onPress={onPressDone}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Save image crop`)}
+          accessibilityHint={_(msg`Saves image crop settings`)}>
+          <LinearGradient
+            colors={[gradients.blueLight.start, gradients.blueLight.end]}
+            start={{x: 0, y: 0}}
+            end={{x: 1, y: 1}}
+            style={[styles.btn]}>
+            <Text type="xl-medium" style={s.white}>
+              <Trans>Done</Trans>
+            </Text>
+          </LinearGradient>
+        </TouchableOpacity>
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  cropper: {
+    marginLeft: 'auto',
+    marginRight: 'auto',
+    borderWidth: 1,
+    borderRadius: 4,
+    overflow: 'hidden',
+    alignItems: 'center',
+  },
+  ctrls: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginTop: 10,
+  },
+  btns: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginTop: 10,
+  },
+  btn: {
+    borderRadius: 4,
+    paddingVertical: 8,
+    paddingHorizontal: 24,
+  },
+})
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index c1024751f..a2acc23bb 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -12,7 +12,7 @@ import * as ChangeEmailModal from './ChangeEmail'
 import * as ChangeHandleModal from './ChangeHandle'
 import * as ChangePasswordModal from './ChangePassword'
 import * as CreateOrEditListModal from './CreateOrEditList'
-import * as CropImageModal from './crop-image/CropImage.web'
+import * as CropImageModal from './CropImage.web'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as EditProfileModal from './EditProfile'
 import * as InviteCodesModal from './InviteCodes'
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
deleted file mode 100644
index 10cae2f17..000000000
--- a/src/view/com/modals/crop-image/CropImage.web.tsx
+++ /dev/null
@@ -1,228 +0,0 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import {LinearGradient} from 'expo-linear-gradient'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {Slider} from '@miblanchard/react-native-slider'
-import ImageEditor from 'react-avatar-editor'
-
-import {useModalControls} from '#/state/modals'
-import {usePalette} from 'lib/hooks/usePalette'
-import {RectTallIcon, RectWideIcon, SquareIcon} from 'lib/icons'
-import {Dimensions} from 'lib/media/types'
-import {getDataUriSize} from 'lib/media/util'
-import {gradients, s} from 'lib/styles'
-import {Text} from 'view/com/util/text/Text'
-import {calculateDimensions} from './cropImageUtil'
-
-enum AspectRatio {
-  Square = 'square',
-  Wide = 'wide',
-  Tall = 'tall',
-  Custom = 'custom',
-}
-
-const DIMS: Record<string, Dimensions> = {
-  [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,
-  dimensions,
-  onSelect,
-}: {
-  uri: string
-  dimensions?: Dimensions
-  onSelect: (img?: RNImage) => void
-}) {
-  const {closeModal} = useModalControls()
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const defaultAspectStyle = dimensions
-    ? AspectRatio.Custom
-    : AspectRatio.Square
-  const [as, setAs] = React.useState<AspectRatio>(defaultAspectStyle)
-  const [scale, setScale] = React.useState<number>(1)
-  const editorRef = React.useRef<ImageEditor>(null)
-  const imageEditorWidth = dimensions ? dimensions.width : DIMS[as].width
-  const imageEditorHeight = dimensions ? dimensions.height : DIMS[as].height
-
-  const doSetAs = (v: AspectRatio) => () => setAs(v)
-
-  const onPressCancel = () => {
-    onSelect(undefined)
-    closeModal()
-  }
-  const onPressDone = () => {
-    const canvas = editorRef.current?.getImageScaledToCanvas()
-    if (canvas) {
-      const dataUri = canvas.toDataURL('image/jpeg')
-      onSelect({
-        path: dataUri,
-        mime: 'image/jpeg',
-        size: getDataUriSize(dataUri),
-        width: imageEditorWidth,
-        height: imageEditorHeight,
-      })
-    } else {
-      onSelect(undefined)
-    }
-    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
-  } else if (as === AspectRatio.Custom) {
-    const cropperDimensions = calculateDimensions(
-      550,
-      imageEditorHeight,
-      imageEditorWidth,
-    )
-    cropperStyle = {
-      width: cropperDimensions.width,
-      height: cropperDimensions.height,
-    }
-  }
-
-  return (
-    <View>
-      <View style={[styles.cropper, pal.borderDark, cropperStyle]}>
-        <ImageEditor
-          ref={editorRef}
-          style={styles.imageEditor}
-          image={uri}
-          width={imageEditorWidth}
-          height={imageEditorHeight}
-          scale={scale}
-          border={0}
-        />
-      </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}
-        />
-        {as === AspectRatio.Custom ? null : (
-          <>
-            <TouchableOpacity
-              onPress={doSetAs(AspectRatio.Wide)}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Wide`)}
-              accessibilityHint={_(msg`Sets image aspect ratio to wide`)}>
-              <RectWideIcon
-                size={24}
-                style={as === AspectRatio.Wide ? s.blue3 : pal.text}
-              />
-            </TouchableOpacity>
-            <TouchableOpacity
-              onPress={doSetAs(AspectRatio.Tall)}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Tall`)}
-              accessibilityHint={_(msg`Sets image aspect ratio to tall`)}>
-              <RectTallIcon
-                size={24}
-                style={as === AspectRatio.Tall ? s.blue3 : pal.text}
-              />
-            </TouchableOpacity>
-            <TouchableOpacity
-              onPress={doSetAs(AspectRatio.Square)}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Square`)}
-              accessibilityHint={_(msg`Sets image aspect ratio to square`)}>
-              <SquareIcon
-                size={24}
-                style={as === AspectRatio.Square ? s.blue3 : pal.text}
-              />
-            </TouchableOpacity>
-          </>
-        )}
-      </View>
-      <View style={styles.btns}>
-        <TouchableOpacity
-          onPress={onPressCancel}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Cancel image crop`)}
-          accessibilityHint={_(msg`Exits image cropping process`)}>
-          <Text type="xl" style={pal.link}>
-            <Trans>Cancel</Trans>
-          </Text>
-        </TouchableOpacity>
-        <View style={s.flex1} />
-        <TouchableOpacity
-          onPress={onPressDone}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Save image crop`)}
-          accessibilityHint={_(msg`Saves image crop settings`)}>
-          <LinearGradient
-            colors={[gradients.blueLight.start, gradients.blueLight.end]}
-            start={{x: 0, y: 0}}
-            end={{x: 1, y: 1}}
-            style={[styles.btn]}>
-            <Text type="xl-medium" style={s.white}>
-              <Trans>Done</Trans>
-            </Text>
-          </LinearGradient>
-        </TouchableOpacity>
-      </View>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  cropper: {
-    marginLeft: 'auto',
-    marginRight: 'auto',
-    borderWidth: 1,
-    borderRadius: 4,
-    overflow: 'hidden',
-  },
-  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/modals/crop-image/cropImageUtil.ts b/src/view/com/modals/crop-image/cropImageUtil.ts
deleted file mode 100644
index 303d15ba5..000000000
--- a/src/view/com/modals/crop-image/cropImageUtil.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export const calculateDimensions = (
-  maxWidth: number,
-  originalHeight: number,
-  originalWidth: number,
-) => {
-  const aspectRatio = originalWidth / originalHeight
-  const newHeight = maxWidth / aspectRatio
-  const newWidth = maxWidth
-  return {
-    width: newWidth,
-    height: newHeight,
-  }
-}
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index b2f56c138..76d9d1503 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -8,17 +8,17 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
-import {logger} from '#/logger'
-import {usePalette} from 'lib/hooks/usePalette'
+import {usePalette} from '#/lib/hooks/usePalette'
 import {
   useCameraPermission,
   usePhotoLibraryPermission,
-} from 'lib/hooks/usePermissions'
-import {makeProfileLink} from 'lib/routes/links'
-import {colors} from 'lib/styles'
-import {isAndroid, isNative, isWeb} from 'platform/detection'
-import {precacheProfile} from 'state/queries/profile'
-import {HighPriorityImage} from 'view/com/util/images/Image'
+} from '#/lib/hooks/usePermissions'
+import {makeProfileLink} from '#/lib/routes/links'
+import {colors} from '#/lib/styles'
+import {logger} from '#/logger'
+import {isAndroid, isNative, isWeb} from '#/platform/detection'
+import {precacheProfile} from '#/state/queries/profile'
+import {HighPriorityImage} from '#/view/com/util/images/Image'
 import {tokens, useTheme} from '#/alf'
 import {
   Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
@@ -321,6 +321,8 @@ let EditableUserAvatar = ({
         height: 1000,
         width: 1000,
         path: item.path,
+        webAspectRatio: 1,
+        webCircularCrop: true,
       })
 
       onSelectNewAvatar(croppedImage)
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 93ea32750..13f4081fc 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -6,16 +6,16 @@ import {ModerationUI} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {logger} from '#/logger'
-import {usePalette} from 'lib/hooks/usePalette'
+import {usePalette} from '#/lib/hooks/usePalette'
 import {
   useCameraPermission,
   usePhotoLibraryPermission,
-} from 'lib/hooks/usePermissions'
-import {colors} from 'lib/styles'
-import {useTheme} from 'lib/ThemeContext'
-import {isAndroid, isNative} from 'platform/detection'
-import {EventStopper} from 'view/com/util/EventStopper'
+} from '#/lib/hooks/usePermissions'
+import {colors} from '#/lib/styles'
+import {useTheme} from '#/lib/ThemeContext'
+import {logger} from '#/logger'
+import {isAndroid, isNative} from '#/platform/detection'
+import {EventStopper} from '#/view/com/util/EventStopper'
 import {tokens, useTheme as useAlfTheme} from '#/alf'
 import {
   Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
@@ -72,6 +72,7 @@ export function UserBanner({
           path: items[0].path,
           width: 3000,
           height: 1000,
+          webAspectRatio: 3,
         }),
       )
     } catch (e: any) {