about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/components/Dialog/context.ts21
-rw-r--r--src/components/Portal.tsx38
-rw-r--r--src/lib/media/manip.ts2
-rw-r--r--src/lib/media/manip.web.ts2
-rw-r--r--src/lib/media/picker.shared.ts10
-rw-r--r--src/lib/media/picker.web.tsx29
-rw-r--r--src/screens/Profile/Header/EditProfileDialog.tsx25
-rw-r--r--src/screens/Profile/Header/ProfileHeaderLabeler.tsx29
-rw-r--r--src/screens/Profile/Header/ProfileHeaderStandard.tsx17
-rw-r--r--src/state/gallery.ts8
-rw-r--r--src/state/modals/index.tsx41
-rw-r--r--src/state/queries/list.ts8
-rw-r--r--src/state/queries/profile.ts10
-rw-r--r--src/view/com/composer/photos/EditImageDialog.tsx10
-rw-r--r--src/view/com/composer/photos/EditImageDialog.web.tsx185
-rw-r--r--src/view/com/modals/CreateOrEditList.tsx20
-rw-r--r--src/view/com/modals/Modal.tsx6
-rw-r--r--src/view/com/modals/Modal.web.tsx11
-rw-r--r--src/view/com/util/EventStopper.tsx4
-rw-r--r--src/view/com/util/UserAvatar.tsx251
-rw-r--r--src/view/com/util/UserBanner.tsx240
21 files changed, 529 insertions, 438 deletions
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
index eb892403f..2ecf5ba61 100644
--- a/src/components/Dialog/context.ts
+++ b/src/components/Dialog/context.ts
@@ -1,4 +1,11 @@
-import React from 'react'
+import {
+  createContext,
+  useContext,
+  useEffect,
+  useId,
+  useMemo,
+  useRef,
+} from 'react'
 
 import {useDialogStateContext} from '#/state/dialogs'
 import {
@@ -8,7 +15,7 @@ import {
 } from '#/components/Dialog/types'
 import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
 
-export const Context = React.createContext<DialogContextProps>({
+export const Context = createContext<DialogContextProps>({
   close: () => {},
   isNativeDialog: false,
   nativeSnapPoint: BottomSheetSnapPoint.Hidden,
@@ -18,18 +25,18 @@ export const Context = React.createContext<DialogContextProps>({
 })
 
 export function useDialogContext() {
-  return React.useContext(Context)
+  return useContext(Context)
 }
 
 export function useDialogControl(): DialogOuterProps['control'] {
-  const id = React.useId()
-  const control = React.useRef<DialogControlRefProps>({
+  const id = useId()
+  const control = useRef<DialogControlRefProps>({
     open: () => {},
     close: () => {},
   })
   const {activeDialogs} = useDialogStateContext()
 
-  React.useEffect(() => {
+  useEffect(() => {
     activeDialogs.current.set(id, control)
     return () => {
       // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -37,7 +44,7 @@ export function useDialogControl(): DialogOuterProps['control'] {
     }
   }, [id, activeDialogs])
 
-  return React.useMemo<DialogOuterProps['control']>(
+  return useMemo<DialogOuterProps['control']>(
     () => ({
       id,
       ref: control,
diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx
index 03b397b2b..4e03d6b08 100644
--- a/src/components/Portal.tsx
+++ b/src/components/Portal.tsx
@@ -1,4 +1,14 @@
-import React from 'react'
+import {
+  createContext,
+  Fragment,
+  useCallback,
+  useContext,
+  useEffect,
+  useId,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
 
 type Component = React.ReactElement
 
@@ -9,32 +19,32 @@ type ContextType = {
 }
 
 type ComponentMap = {
-  [id: string]: Component
+  [id: string]: Component | null
 }
 
 export function createPortalGroup() {
-  const Context = React.createContext<ContextType>({
+  const Context = createContext<ContextType>({
     outlet: null,
     append: () => {},
     remove: () => {},
   })
 
   function Provider(props: React.PropsWithChildren<{}>) {
-    const map = React.useRef<ComponentMap>({})
-    const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
+    const map = useRef<ComponentMap>({})
+    const [outlet, setOutlet] = useState<ContextType['outlet']>(null)
 
-    const append = React.useCallback<ContextType['append']>((id, component) => {
+    const append = useCallback<ContextType['append']>((id, component) => {
       if (map.current[id]) return
-      map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
+      map.current[id] = <Fragment key={id}>{component}</Fragment>
       setOutlet(<>{Object.values(map.current)}</>)
     }, [])
 
-    const remove = React.useCallback<ContextType['remove']>(id => {
-      delete map.current[id]
+    const remove = useCallback<ContextType['remove']>(id => {
+      map.current[id] = null
       setOutlet(<>{Object.values(map.current)}</>)
     }, [])
 
-    const contextValue = React.useMemo(
+    const contextValue = useMemo(
       () => ({
         outlet,
         append,
@@ -49,14 +59,14 @@ export function createPortalGroup() {
   }
 
   function Outlet() {
-    const ctx = React.useContext(Context)
+    const ctx = useContext(Context)
     return ctx.outlet
   }
 
   function Portal({children}: React.PropsWithChildren<{}>) {
-    const {append, remove} = React.useContext(Context)
-    const id = React.useId()
-    React.useEffect(() => {
+    const {append, remove} = useContext(Context)
+    const id = useId()
+    useEffect(() => {
       append(id, children as Component)
       return () => remove(id)
     }, [id, children, append, remove])
diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts
index ff5b71ace..4578406e0 100644
--- a/src/lib/media/manip.ts
+++ b/src/lib/media/manip.ts
@@ -24,7 +24,7 @@ import {type Dimensions} from './types'
 
 export async function compressIfNeeded(
   img: PickerImage,
-  maxSize: number = 1000000,
+  maxSize: number = POST_IMG_MAX.size,
 ): Promise<PickerImage> {
   if (img.size < maxSize) {
     return img
diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts
index ffcf0c533..f23b955d6 100644
--- a/src/lib/media/manip.web.ts
+++ b/src/lib/media/manip.web.ts
@@ -1,3 +1,5 @@
+/// <reference lib="dom" />
+
 import {type PickerImage} from './picker.shared'
 import {type Dimensions} from './types'
 import {blobToDataUri, getDataUriSize} from './util'
diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts
index 21e680832..8fd76f414 100644
--- a/src/lib/media/picker.shared.ts
+++ b/src/lib/media/picker.shared.ts
@@ -1,25 +1,21 @@
 import {
   type ImagePickerOptions,
   launchImageLibraryAsync,
-  MediaTypeOptions,
 } from 'expo-image-picker'
 import {t} from '@lingui/macro'
 
+import {type ImageMeta} from '#/state/gallery'
 import * as Toast from '#/view/com/util/Toast'
 import {getDataUriSize} from './util'
 
-export type PickerImage = {
-  mime: string
-  height: number
-  width: number
-  path: string
+export type PickerImage = ImageMeta & {
   size: number
 }
 
 export async function openPicker(opts?: ImagePickerOptions) {
   const response = await launchImageLibraryAsync({
     exif: false,
-    mediaTypes: MediaTypeOptions.Images,
+    mediaTypes: ['images'],
     quality: 1,
     ...opts,
     legacy: true,
diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx
index b7d0d6f06..c1e4e4ab7 100644
--- a/src/lib/media/picker.web.tsx
+++ b/src/lib/media/picker.web.tsx
@@ -1,35 +1,18 @@
-/// <reference lib="dom" />
-
 import {type OpenCropperOptions} from 'expo-image-crop-tool'
 
-import {unstable__openModal} from '#/state/modals'
 import {type PickerImage} from './picker.shared'
 import {type CameraOpts} from './types'
 
-export {openPicker, type PickerImage as RNImage} from './picker.shared'
+export {openPicker} from './picker.shared'
 
 export async function openCamera(_opts: CameraOpts): Promise<PickerImage> {
-  // const mediaType = opts.mediaType || 'photo' TODO
-  throw new Error('TODO')
+  throw new Error('openCamera is not supported on web')
 }
 
 export async function openCropper(
-  opts: OpenCropperOptions,
+  _opts: OpenCropperOptions,
 ): Promise<PickerImage> {
-  // TODO handle more opts
-  return new Promise((resolve, reject) => {
-    unstable__openModal({
-      name: 'crop-image',
-      uri: opts.imageUri,
-      aspect: opts.aspectRatio,
-      circular: opts.shape === 'circle',
-      onSelect: (img?: PickerImage) => {
-        if (img) {
-          resolve(img)
-        } else {
-          reject(new Error('Canceled'))
-        }
-      },
-    })
-  })
+  throw new Error(
+    'openCropper is not supported on web. Use EditImageDialog instead.',
+  )
 }
diff --git a/src/screens/Profile/Header/EditProfileDialog.tsx b/src/screens/Profile/Header/EditProfileDialog.tsx
index 8a9f0d540..8d86a023b 100644
--- a/src/screens/Profile/Header/EditProfileDialog.tsx
+++ b/src/screens/Profile/Header/EditProfileDialog.tsx
@@ -5,12 +5,11 @@ 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'
 import {isWeb} from '#/platform/detection'
+import {type ImageMeta} from '#/state/gallery'
 import {useProfileUpdateMutation} from '#/state/queries/profile'
 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
 import * as Toast from '#/view/com/util/Toast'
@@ -18,10 +17,11 @@ import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
 import {UserBanner} from '#/view/com/util/UserBanner'
 import {atoms as a, useTheme} from '#/alf'
 import {Admonition} from '#/components/Admonition'
-import {Button, ButtonText} from '#/components/Button'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import * as TextField from '#/components/forms/TextField'
 import {InlineLinkText} from '#/components/Link'
+import {Loader} from '#/components/Loader'
 import * as Prompt from '#/components/Prompt'
 import {useSimpleVerificationState} from '#/components/verification'
 
@@ -127,10 +127,10 @@ function DialogInner({
     profile.avatar,
   )
   const [newUserBanner, setNewUserBanner] = useState<
-    PickerImage | undefined | null
+    ImageMeta | undefined | null
   >()
   const [newUserAvatar, setNewUserAvatar] = useState<
-    PickerImage | undefined | null
+    ImageMeta | undefined | null
   >()
 
   const dirty =
@@ -144,7 +144,7 @@ function DialogInner({
   }, [dirty, setDirty])
 
   const onSelectNewAvatar = useCallback(
-    async (img: PickerImage | null) => {
+    (img: ImageMeta | null) => {
       setImageError('')
       if (img === null) {
         setNewUserAvatar(null)
@@ -152,9 +152,8 @@ function DialogInner({
         return
       }
       try {
-        const finalImg = await compressIfNeeded(img, 1000000)
-        setNewUserAvatar(finalImg)
-        setUserAvatar(finalImg.path)
+        setNewUserAvatar(img)
+        setUserAvatar(img.path)
       } catch (e: any) {
         setImageError(cleanError(e))
       }
@@ -163,7 +162,7 @@ function DialogInner({
   )
 
   const onSelectNewBanner = useCallback(
-    async (img: PickerImage | null) => {
+    (img: ImageMeta | null) => {
       setImageError('')
       if (!img) {
         setNewUserBanner(null)
@@ -171,9 +170,8 @@ function DialogInner({
         return
       }
       try {
-        const finalImg = await compressIfNeeded(img, 1000000)
-        setNewUserBanner(finalImg)
-        setUserBanner(finalImg.path)
+        setNewUserBanner(img)
+        setUserBanner(img.path)
       } catch (e: any) {
         setImageError(cleanError(e))
       }
@@ -258,6 +256,7 @@ function DialogInner({
         <ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}>
           <Trans>Save</Trans>
         </ButtonText>
+        {isUpdatingProfile && <ButtonIcon icon={Loader} />}
       </Button>
     ),
     [
diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
index d355b9987..cdb95423f 100644
--- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
@@ -1,11 +1,11 @@
 import React, {memo, useMemo} from 'react'
 import {View} from 'react-native'
 import {
-  AppBskyActorDefs,
-  AppBskyLabelerDefs,
+  type AppBskyActorDefs,
+  type AppBskyLabelerDefs,
   moderateProfile,
-  ModerationOpts,
-  RichText as RichTextAPI,
+  type ModerationOpts,
+  type RichText as RichTextAPI,
 } from '@atproto/api'
 import {msg, Plural, plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -15,10 +15,9 @@ import {MAX_LABELERS} from '#/lib/constants'
 import {useHaptics} from '#/lib/haptics'
 import {isAppLabeler} from '#/lib/moderation'
 import {logger} from '#/logger'
-import {isIOS, isWeb} from '#/platform/detection'
+import {isIOS} from '#/platform/detection'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
-import {Shadow} from '#/state/cache/types'
-import {useModalControls} from '#/state/modals'
+import {type Shadow} from '#/state/cache/types'
 import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
 import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
 import {usePreferencesQuery} from '#/state/queries/preferences'
@@ -27,7 +26,7 @@ import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, tokens, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
-import {DialogOuterProps, useDialogControl} from '#/components/Dialog'
+import {type DialogOuterProps, useDialogControl} from '#/components/Dialog'
 import {
   Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
   Heart2_Stroke2_Corner0_Rounded as Heart,
@@ -117,19 +116,7 @@ let ProfileHeaderLabeler = ({
     }
   }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
 
-  const {openModal} = useModalControls()
   const editProfileControl = useDialogControl()
-  const onPressEditProfile = React.useCallback(() => {
-    if (isWeb) {
-      // temp, while we figure out the nested dialog bug
-      openModal({
-        name: 'edit-profile',
-        profile,
-      })
-    } else {
-      editProfileControl.open()
-    }
-  }, [editProfileControl, openModal, profile])
 
   const onPressSubscribe = React.useCallback(
     () =>
@@ -192,7 +179,7 @@ let ProfileHeaderLabeler = ({
                 size="small"
                 color="secondary"
                 variant="solid"
-                onPress={onPressEditProfile}
+                onPress={editProfileControl.open}
                 label={_(msg`Edit profile`)}
                 style={a.rounded_full}>
                 <ButtonText>
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 1c4c4d9f3..2dff101e6 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -12,10 +12,9 @@ import {useLingui} from '@lingui/react'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {logger} from '#/logger'
-import {isIOS, isWeb} from '#/platform/detection'
+import {isIOS} from '#/platform/detection'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {type Shadow} from '#/state/cache/types'
-import {useModalControls} from '#/state/modals'
 import {
   useProfileBlockMutationQueue,
   useProfileFollowMutationQueue,
@@ -78,19 +77,7 @@ let ProfileHeaderStandard = ({
     profile.viewer?.blockedBy ||
     profile.viewer?.blockingByList
 
-  const {openModal} = useModalControls()
   const editProfileControl = useDialogControl()
-  const onPressEditProfile = React.useCallback(() => {
-    if (isWeb) {
-      // temp, while we figure out the nested dialog bug
-      openModal({
-        name: 'edit-profile',
-        profile,
-      })
-    } else {
-      editProfileControl.open()
-    }
-  }, [editProfileControl, openModal, profile])
 
   const onPressFollow = () => {
     requireAuth(async () => {
@@ -178,7 +165,7 @@ let ProfileHeaderStandard = ({
                 size="small"
                 color="secondary"
                 variant="solid"
-                onPress={onPressEditProfile}
+                onPress={editProfileControl.open}
                 label={_(msg`Edit profile`)}
                 style={[a.rounded_full]}>
                 <ButtonText>
diff --git a/src/state/gallery.ts b/src/state/gallery.ts
index 73252dde7..3e989f030 100644
--- a/src/state/gallery.ts
+++ b/src/state/gallery.ts
@@ -15,6 +15,7 @@ import {nanoid} from 'nanoid/non-secure'
 import {POST_IMG_MAX} from '#/lib/constants'
 import {getImageDim} from '#/lib/media/manip'
 import {openCropper} from '#/lib/media/picker'
+import {type PickerImage} from '#/lib/media/picker.shared'
 import {getDataUriSize} from '#/lib/media/util'
 import {isNative} from '#/platform/detection'
 
@@ -194,7 +195,7 @@ export function resetImageManipulation(
   return img
 }
 
-export async function compressImage(img: ComposerImage): Promise<ImageMeta> {
+export async function compressImage(img: ComposerImage): Promise<PickerImage> {
   const source = img.transformed || img.source
 
   const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
@@ -219,14 +220,15 @@ export async function compressImage(img: ComposerImage): Promise<ImageMeta> {
     )
 
     const base64 = res.base64
-
-    if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) {
+    const size = base64 ? getDataUriSize(base64) : 0
+    if (base64 && size <= POST_IMG_MAX.size) {
       minQualityPercentage = qualityPercentage
       newDataUri = {
         path: await moveIfNecessary(res.uri),
         width: res.width,
         height: res.height,
         mime: 'image/jpeg',
+        size,
       }
     } else {
       maxQualityPercentage = qualityPercentage
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 3e738898a..7ebcec4c7 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -1,14 +1,7 @@
 import React from 'react'
-import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api'
+import {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'
-  profile: AppBskyActorDefs.ProfileViewDetailed
-  onUpdate?: () => void
-}
 
 export interface CreateOrEditListModal {
   name: 'create-or-edit-list'
@@ -26,15 +19,6 @@ export interface UserAddRemoveListsModal {
   onRemove?: (listUri: string) => void
 }
 
-export interface CropImageModal {
-  name: 'crop-image'
-  uri: string
-  dimensions?: {width: number; height: number}
-  aspect?: number
-  circular?: boolean
-  onSelect: (img?: PickerImage) => void
-}
-
 export interface DeleteAccountModal {
   name: 'delete-account'
 }
@@ -71,9 +55,6 @@ export type Modal =
   | DeleteAccountModal
   | ChangePasswordModal
 
-  // Temp
-  | EditProfileModal
-
   // Curation
   | ContentLanguagesSettingsModal
   | PostLanguagesSettingsModal
@@ -82,9 +63,6 @@ export type Modal =
   | CreateOrEditListModal
   | UserAddRemoveListsModal
 
-  // Posts
-  | CropImageModal
-
   // Bluesky access
   | WaitlistModal
   | InviteCodesModal
@@ -110,20 +88,6 @@ const ModalControlContext = React.createContext<{
   closeAllModals: () => false,
 })
 
-/**
- * @deprecated DO NOT USE THIS unless you have no other choice.
- */
-export let unstable__openModal: (modal: Modal) => void = () => {
-  throw new Error(`ModalContext is not initialized`)
-}
-
-/**
- * @deprecated DO NOT USE THIS unless you have no other choice.
- */
-export let unstable__closeModal: () => boolean = () => {
-  throw new Error(`ModalContext is not initialized`)
-}
-
 export function Provider({children}: React.PropsWithChildren<{}>) {
   const [activeModals, setActiveModals] = React.useState<Modal[]>([])
 
@@ -145,9 +109,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     return wasActive
   })
 
-  unstable__openModal = openModal
-  unstable__closeModal = closeModal
-
   const state = React.useMemo(
     () => ({
       isModalActive: activeModals.length > 0,
diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts
index 38e8cd62d..c0d5edfb1 100644
--- a/src/state/queries/list.ts
+++ b/src/state/queries/list.ts
@@ -14,9 +14,9 @@ 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 {type ImageMeta} from '#/state/gallery'
 import {STALE} from '#/state/queries'
-import {useAgent, useSession} from '../session'
+import {useAgent, useSession} from '#/state/session'
 import {invalidate as invalidateMyLists} from './my-lists'
 import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
 
@@ -47,7 +47,7 @@ export interface ListCreateMutateParams {
   name: string
   description: string
   descriptionFacets: Facet[] | undefined
-  avatar: PickerImage | null | undefined
+  avatar: ImageMeta | null | undefined
 }
 export function useListCreateMutation() {
   const {currentAccount} = useSession()
@@ -115,7 +115,7 @@ export interface ListMetadataMutateParams {
   name: string
   description: string
   descriptionFacets: Facet[] | undefined
-  avatar: PickerImage | null | undefined
+  avatar: ImageMeta | null | undefined
 }
 export function useListMetadataMutation() {
   const {currentAccount} = useSession()
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 9f40ab7f6..eb65fef7c 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -20,9 +20,10 @@ 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 {updateProfileShadow} from '#/state/cache/profile-shadow'
 import {type Shadow} from '#/state/cache/types'
+import {type ImageMeta} from '#/state/gallery'
 import {STALE} from '#/state/queries'
 import {resetProfilePostsQueries} from '#/state/queries/post-feed'
 import {
@@ -30,10 +31,9 @@ import {
   useUnstableProfileViewCache,
 } from '#/state/queries/unstable-profile-cache'
 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache'
+import {useAgent, useSession} from '#/state/session'
 import * as userActionHistory from '#/state/userActionHistory'
 import type * as bsky from '#/types/bsky'
-import {updateProfileShadow} from '../cache/profile-shadow'
-import {useAgent, useSession} from '../session'
 import {
   ProgressGuideAction,
   useProgressGuideControls,
@@ -131,8 +131,8 @@ interface ProfileUpdateParams {
     | ((
         existing: Un$Typed<AppBskyActorProfile.Record>,
       ) => Un$Typed<AppBskyActorProfile.Record>)
-  newUserAvatar?: PickerImage | undefined | null
-  newUserBanner?: PickerImage | undefined | null
+  newUserAvatar?: ImageMeta | undefined | null
+  newUserBanner?: ImageMeta | undefined | null
   checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean
 }
 export function useProfileUpdateMutation() {
diff --git a/src/view/com/composer/photos/EditImageDialog.tsx b/src/view/com/composer/photos/EditImageDialog.tsx
index 4263587fd..9799f7b82 100644
--- a/src/view/com/composer/photos/EditImageDialog.tsx
+++ b/src/view/com/composer/photos/EditImageDialog.tsx
@@ -1,12 +1,14 @@
-import React from 'react'
+import type React from 'react'
 
-import {ComposerImage} from '#/state/gallery'
-import * as Dialog from '#/components/Dialog'
+import {type ComposerImage} from '#/state/gallery'
+import type * as Dialog from '#/components/Dialog'
 
 export type EditImageDialogProps = {
   control: Dialog.DialogOuterProps['control']
-  image: ComposerImage
+  image?: ComposerImage
   onChange: (next: ComposerImage) => void
+  aspectRatio?: number
+  circularCrop?: boolean
 }
 
 export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx
index ebe528abc..9a170736a 100644
--- a/src/view/com/composer/photos/EditImageDialog.web.tsx
+++ b/src/view/com/composer/photos/EditImageDialog.web.tsx
@@ -1,43 +1,130 @@
 import 'react-image-crop/dist/ReactCrop.css'
 
-import React from 'react'
+import {useCallback, useImperativeHandle, useRef, useState} 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 ReactCrop, {type PercentCrop} from 'react-image-crop'
 
 import {
-  ImageSource,
-  ImageTransformation,
+  type ImageSource,
+  type ImageTransformation,
   manipulateImage,
 } from '#/state/gallery'
-import {atoms as a} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
-import {Text} from '#/components/Typography'
-import {EditImageDialogProps} from './EditImageDialog'
+import {Loader} from '#/components/Loader'
+import {type EditImageDialogProps} from './EditImageDialog'
 
-export const EditImageDialog = (props: EditImageDialogProps) => {
+export function EditImageDialog(props: EditImageDialogProps) {
   return (
     <Dialog.Outer control={props.control}>
       <Dialog.Handle />
-      <EditImageInner key={props.image.source.id} {...props} />
+      <DialogInner {...props} />
     </Dialog.Outer>
   )
 }
 
-const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
+function DialogInner({
+  control,
+  image,
+  onChange,
+  circularCrop,
+  aspectRatio,
+}: EditImageDialogProps) {
   const {_} = useLingui()
+  const [pending, setPending] = useState(false)
+  const ref = useRef<{save: () => Promise<void>}>(null)
+
+  const cancelButton = useCallback(
+    () => (
+      <Button
+        label={_(msg`Cancel`)}
+        disabled={pending}
+        onPress={() => control.close()}
+        size="small"
+        color="primary"
+        variant="ghost"
+        style={[a.rounded_full]}
+        testID="cropImageCancelBtn">
+        <ButtonText style={[a.text_md]}>
+          <Trans>Cancel</Trans>
+        </ButtonText>
+      </Button>
+    ),
+    [control, _, pending],
+  )
+
+  const saveButton = useCallback(
+    () => (
+      <Button
+        label={_(msg`Save`)}
+        onPress={async () => {
+          setPending(true)
+          await ref.current?.save()
+          setPending(false)
+        }}
+        disabled={pending}
+        size="small"
+        color="primary"
+        variant="ghost"
+        style={[a.rounded_full]}
+        testID="cropImageSaveBtn">
+        <ButtonText style={[a.text_md]}>
+          <Trans>Save</Trans>
+        </ButtonText>
+        {pending && <ButtonIcon icon={Loader} />}
+      </Button>
+    ),
+    [_, pending],
+  )
+
+  return (
+    <Dialog.Inner
+      label={_(msg`Edit image`)}
+      header={
+        <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}>
+          <Dialog.HeaderText>
+            <Trans>Edit image</Trans>
+          </Dialog.HeaderText>
+        </Dialog.Header>
+      }>
+      {image && (
+        <EditImageInner
+          saveRef={ref}
+          key={image.source.id}
+          image={image}
+          onChange={onChange}
+          circularCrop={circularCrop}
+          aspectRatio={aspectRatio}
+        />
+      )}
+    </Dialog.Inner>
+  )
+}
+
+function EditImageInner({
+  image,
+  onChange,
+  saveRef,
+  circularCrop = false,
+  aspectRatio,
+}: Required<Pick<EditImageDialogProps, 'image'>> &
+  Omit<EditImageDialogProps, 'control' | 'image'> & {
+    saveRef: React.RefObject<{save: () => Promise<void>}>
+  }) {
+  const t = useTheme()
+  const [isDragging, setIsDragging] = useState(false)
+  const {_} = useLingui()
+  const control = Dialog.useDialogContext()
 
   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 [crop, setCrop] = useState(initialCrop)
 
-  const onPressSubmit = React.useCallback(async () => {
+  const onPressSubmit = useCallback(async () => {
     const result = await manipulateImage(image, {
       crop:
         crop && (crop.width || crop.height) !== 0
@@ -50,41 +137,43 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
           : undefined,
     })
 
-    onChange(result)
-    control.close()
+    control.close(() => {
+      onChange(result)
+    })
   }, [crop, image, source, control, onChange])
 
+  useImperativeHandle(
+    saveRef,
+    () => ({
+      save: onPressSubmit,
+    }),
+    [onPressSubmit],
+  )
+
   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>
+    <View
+      style={[
+        a.mx_auto,
+        a.border,
+        t.atoms.border_contrast_low,
+        a.rounded_xs,
+        a.overflow_hidden,
+        a.align_center,
+      ]}>
+      <ReactCrop
+        crop={crop}
+        aspect={aspectRatio}
+        circularCrop={circularCrop}
+        onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
+        className="ReactCrop--no-animate"
+        onDragStart={() => setIsDragging(true)}
+        onDragEnd={() => setIsDragging(false)}>
+        <img src={source.path} style={{maxHeight: `50vh`}} />
+      </ReactCrop>
+      {/* Eat clicks when dragging, otherwise mousing up over the backdrop
+        causes the dialog to close */}
+      {isDragging && <View style={[a.fixed, a.inset_0]} />}
+    </View>
   )
 }
 
diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx
index a7eae15dd..3687dce90 100644
--- a/src/view/com/modals/CreateOrEditList.tsx
+++ b/src/view/com/modals/CreateOrEditList.tsx
@@ -15,24 +15,23 @@ 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'
 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
 import {colors, gradients, s} from '#/lib/styles'
 import {useTheme} from '#/lib/ThemeContext'
+import {type ImageMeta} from '#/state/gallery'
 import {useModalControls} from '#/state/modals'
 import {
   useListCreateMutation,
   useListMetadataMutation,
 } from '#/state/queries/list'
 import {useAgent} from '#/state/session'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {Text} from '../util/text/Text'
-import * as Toast from '../util/Toast'
-import {EditableUserAvatar} from '../util/UserAvatar'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
+import {Text} from '#/view/com/util/text/Text'
+import * as Toast from '#/view/com/util/Toast'
+import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
 
 const MAX_NAME = 64 // todo
 const MAX_DESCRIPTION = 300 // todo
@@ -95,7 +94,7 @@ export function Component({
   const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
 
   const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
-  const [newAvatar, setNewAvatar] = useState<PickerImage | undefined | null>()
+  const [newAvatar, setNewAvatar] = useState<ImageMeta | undefined | null>()
 
   const onDescriptionChange = useCallback(
     (newText: string) => {
@@ -112,16 +111,15 @@ export function Component({
   }, [closeModal])
 
   const onSelectNewAvatar = useCallback(
-    async (img: PickerImage | null) => {
+    (img: ImageMeta | null) => {
       if (!img) {
         setNewAvatar(null)
         setAvatar(undefined)
         return
       }
       try {
-        const finalImg = await compressIfNeeded(img, 1000000)
-        setNewAvatar(finalImg)
-        setAvatar(finalImg.path)
+        setNewAvatar(img)
+        setAvatar(img.path)
       } catch (e: any) {
         setError(cleanError(e))
       }
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 8fd927f16..1ec4052d0 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -10,7 +10,6 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
 import * as ChangePasswordModal from './ChangePassword'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as DeleteAccountModal from './DeleteAccount'
-import * as EditProfileModal from './EditProfile'
 import * as InviteCodesModal from './InviteCodes'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
@@ -48,10 +47,7 @@ export function ModalsContainer() {
 
   let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
   let element
-  if (activeModal?.name === 'edit-profile') {
-    snapPoints = EditProfileModal.snapPoints
-    element = <EditProfileModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'create-or-edit-list') {
+  if (activeModal?.name === 'create-or-edit-list') {
     snapPoints = CreateOrEditListModal.snapPoints
     element = <CreateOrEditListModal.Component {...activeModal} />
   } else if (activeModal?.name === 'user-add-remove-lists') {
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 524780099..3374c3132 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -8,9 +8,7 @@ import {type Modal as ModalIface} from '#/state/modals'
 import {useModalControls, useModals} from '#/state/modals'
 import * as ChangePasswordModal from './ChangePassword'
 import * as CreateOrEditListModal from './CreateOrEditList'
-import * as CropImageModal from './CropImage.web'
 import * as DeleteAccountModal from './DeleteAccount'
-import * as EditProfileModal from './EditProfile'
 import * as InviteCodesModal from './InviteCodes'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
@@ -45,9 +43,6 @@ function Modal({modal}: {modal: ModalIface}) {
   }
 
   const onPressMask = () => {
-    if (modal.name === 'crop-image') {
-      return // dont close on mask presses during crop
-    }
     closeModal()
   }
   const onInnerPress = () => {
@@ -56,14 +51,10 @@ function Modal({modal}: {modal: ModalIface}) {
   }
 
   let element
-  if (modal.name === 'edit-profile') {
-    element = <EditProfileModal.Component {...modal} />
-  } else if (modal.name === 'create-or-edit-list') {
+  if (modal.name === 'create-or-edit-list') {
     element = <CreateOrEditListModal.Component {...modal} />
   } else if (modal.name === 'user-add-remove-lists') {
     element = <UserAddRemoveLists.Component {...modal} />
-  } else if (modal.name === 'crop-image') {
-    element = <CropImageModal.Component {...modal} />
   } else if (modal.name === 'delete-account') {
     element = <DeleteAccountModal.Component />
   } else if (modal.name === 'invite-codes') {
diff --git a/src/view/com/util/EventStopper.tsx b/src/view/com/util/EventStopper.tsx
index 8f5f5cf54..24bc47afd 100644
--- a/src/view/com/util/EventStopper.tsx
+++ b/src/view/com/util/EventStopper.tsx
@@ -1,5 +1,5 @@
-import React from 'react'
-import {View, ViewStyle} from 'react-native'
+import {View, type ViewStyle} from 'react-native'
+import type React from 'react'
 
 /**
  * This utility function captures events and stops
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 2450c111b..326a2fff8 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -1,4 +1,4 @@
-import React, {memo, useMemo} from 'react'
+import React, {memo, useCallback, useMemo, useState} from 'react'
 import {
   Image,
   Pressable,
@@ -14,36 +14,38 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
-import {usePalette} from '#/lib/hooks/usePalette'
 import {
   useCameraPermission,
   usePhotoLibraryPermission,
 } from '#/lib/hooks/usePermissions'
+import {compressIfNeeded} from '#/lib/media/manip'
+import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
+import {type PickerImage} from '#/lib/media/picker.shared'
 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 {
+  type ComposerImage,
+  compressImage,
+  createComposerImage,
+} from '#/state/gallery'
+import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
+import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
 import {HighPriorityImage} from '#/view/com/util/images/Image'
-import {tokens, useTheme} from '#/alf'
+import {atoms as a, tokens, useTheme} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
 import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
 import {
-  Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
-  Camera_Stroke2_Corner0_Rounded as Camera,
+  Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon,
+  Camera_Stroke2_Corner0_Rounded as CameraIcon,
 } from '#/components/icons/Camera'
-import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
-import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
 import {Link} from '#/components/Link'
 import {MediaInsetBorder} from '#/components/MediaInsetBorder'
 import * as Menu from '#/components/Menu'
 import {ProfileHoverCard} from '#/components/ProfileHoverCard'
 import type * as bsky from '#/types/bsky'
-import {
-  openCamera,
-  openCropper,
-  openPicker,
-  type RNImage,
-} from '../../../lib/media/picker'
 
 export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
 
@@ -63,7 +65,7 @@ interface UserAvatarProps extends BaseUserAvatarProps {
 }
 
 interface EditableUserAvatarProps extends BaseUserAvatarProps {
-  onSelectNewAvatar: (img: RNImage | null) => void
+  onSelectNewAvatar: (img: PickerImage | null) => void
 }
 
 interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
@@ -195,8 +197,8 @@ let UserAvatar = ({
   onLoad,
   style,
 }: UserAvatarProps): React.ReactNode => {
-  const pal = usePalette('default')
-  const backgroundColor = pal.colors.backgroundLight
+  const t = useTheme()
+  const backgroundColor = t.palette.contrast_25
   const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
 
   const aviStyle = useMemo(() => {
@@ -221,15 +223,22 @@ let UserAvatar = ({
       return null
     }
     return (
-      <View style={[styles.alertIconContainer, pal.view]}>
+      <View
+        style={[
+          a.absolute,
+          a.right_0,
+          a.bottom_0,
+          a.rounded_full,
+          {backgroundColor: t.palette.white},
+        ]}>
         <FontAwesomeIcon
           icon="exclamation-circle"
-          style={styles.alertIcon}
+          style={{color: t.palette.negative_400}}
           size={Math.floor(size / 3)}
         />
       </View>
     )
-  }, [moderation?.alert, size, pal])
+  }, [moderation?.alert, size, t])
 
   const containerStyle = useMemo(() => {
     return [
@@ -288,14 +297,18 @@ let EditableUserAvatar = ({
   onSelectNewAvatar,
 }: EditableUserAvatarProps): React.ReactNode => {
   const t = useTheme()
-  const pal = usePalette('default')
   const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
+  const [rawImage, setRawImage] = useState<ComposerImage | undefined>()
+  const editImageDialogControl = useDialogControl()
+
   const sheetWrapper = useSheetWrapper()
 
+  const circular = type !== 'algo' && type !== 'list'
+
   const aviStyle = useMemo(() => {
-    if (type === 'algo' || type === 'list') {
+    if (!circular) {
       return {
         width: size,
         height: size,
@@ -307,7 +320,7 @@ let EditableUserAvatar = ({
       height: size,
       borderRadius: Math.floor(size / 2),
     }
-  }, [type, size])
+  }, [circular, size])
 
   const onOpenCamera = React.useCallback(async () => {
     if (!(await requestCameraAccessIfNeeded())) {
@@ -315,9 +328,11 @@ let EditableUserAvatar = ({
     }
 
     onSelectNewAvatar(
-      await openCamera({
-        aspect: [1, 1],
-      }),
+      await compressIfNeeded(
+        await openCamera({
+          aspect: [1, 1],
+        }),
+      ),
     )
   }, [onSelectNewAvatar, requestCameraAccessIfNeeded])
 
@@ -337,91 +352,129 @@ let EditableUserAvatar = ({
     }
 
     try {
-      const croppedImage = await openCropper({
-        imageUri: item.path,
-        shape: 'circle',
-        aspectRatio: 1,
-      })
-      onSelectNewAvatar(croppedImage)
+      if (isNative) {
+        onSelectNewAvatar(
+          await compressIfNeeded(
+            await openCropper({
+              imageUri: item.path,
+              shape: circular ? 'circle' : 'rectangle',
+              aspectRatio: 1,
+            }),
+          ),
+        )
+      } else {
+        setRawImage(await createComposerImage(item))
+        editImageDialogControl.open()
+      }
     } catch (e: any) {
       // Don't log errors for cancelling selection to sentry on ios or android
       if (!String(e).toLowerCase().includes('cancel')) {
         logger.error('Failed to crop banner', {error: e})
       }
     }
-  }, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper])
+  }, [
+    onSelectNewAvatar,
+    requestPhotoAccessIfNeeded,
+    sheetWrapper,
+    editImageDialogControl,
+    circular,
+  ])
 
   const onRemoveAvatar = React.useCallback(() => {
     onSelectNewAvatar(null)
   }, [onSelectNewAvatar])
 
+  const onChangeEditImage = useCallback(
+    async (image: ComposerImage) => {
+      const compressed = await compressImage(image)
+      onSelectNewAvatar(compressed)
+    },
+    [onSelectNewAvatar],
+  )
+
   return (
-    <Menu.Root>
-      <Menu.Trigger label={_(msg`Edit avatar`)}>
-        {({props}) => (
-          <Pressable {...props} testID="changeAvatarBtn">
-            {avatar ? (
-              <HighPriorityImage
-                testID="userAvatarImage"
-                style={aviStyle}
-                source={{uri: avatar}}
-                accessibilityRole="image"
-              />
-            ) : (
-              <DefaultAvatar type={type} size={size} />
+    <>
+      <Menu.Root>
+        <Menu.Trigger label={_(msg`Edit avatar`)}>
+          {({props}) => (
+            <Pressable {...props} testID="changeAvatarBtn">
+              {avatar ? (
+                <HighPriorityImage
+                  testID="userAvatarImage"
+                  style={aviStyle}
+                  source={{uri: avatar}}
+                  accessibilityRole="image"
+                />
+              ) : (
+                <DefaultAvatar type={type} size={size} />
+              )}
+              <View
+                style={[
+                  styles.editButtonContainer,
+                  t.atoms.bg_contrast_25,
+                  a.border,
+                  t.atoms.border_contrast_low,
+                ]}>
+                <CameraFilledIcon height={14} width={14} style={t.atoms.text} />
+              </View>
+            </Pressable>
+          )}
+        </Menu.Trigger>
+        <Menu.Outer showCancel>
+          <Menu.Group>
+            {isNative && (
+              <Menu.Item
+                testID="changeAvatarCameraBtn"
+                label={_(msg`Upload from Camera`)}
+                onPress={onOpenCamera}>
+                <Menu.ItemText>
+                  <Trans>Upload from Camera</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={CameraIcon} />
+              </Menu.Item>
             )}
-            <View style={[styles.editButtonContainer, pal.btn]}>
-              <CameraFilled height={14} width={14} style={t.atoms.text} />
-            </View>
-          </Pressable>
-        )}
-      </Menu.Trigger>
-      <Menu.Outer showCancel>
-        <Menu.Group>
-          {isNative && (
+
             <Menu.Item
-              testID="changeAvatarCameraBtn"
-              label={_(msg`Upload from Camera`)}
-              onPress={onOpenCamera}>
+              testID="changeAvatarLibraryBtn"
+              label={_(msg`Upload from Library`)}
+              onPress={onOpenLibrary}>
               <Menu.ItemText>
-                <Trans>Upload from Camera</Trans>
+                {isNative ? (
+                  <Trans>Upload from Library</Trans>
+                ) : (
+                  <Trans>Upload from Files</Trans>
+                )}
               </Menu.ItemText>
-              <Menu.ItemIcon icon={Camera} />
+              <Menu.ItemIcon icon={LibraryIcon} />
             </Menu.Item>
+          </Menu.Group>
+          {!!avatar && (
+            <>
+              <Menu.Divider />
+              <Menu.Group>
+                <Menu.Item
+                  testID="changeAvatarRemoveBtn"
+                  label={_(msg`Remove Avatar`)}
+                  onPress={onRemoveAvatar}>
+                  <Menu.ItemText>
+                    <Trans>Remove Avatar</Trans>
+                  </Menu.ItemText>
+                  <Menu.ItemIcon icon={TrashIcon} />
+                </Menu.Item>
+              </Menu.Group>
+            </>
           )}
+        </Menu.Outer>
+      </Menu.Root>
 
-          <Menu.Item
-            testID="changeAvatarLibraryBtn"
-            label={_(msg`Upload from Library`)}
-            onPress={onOpenLibrary}>
-            <Menu.ItemText>
-              {isNative ? (
-                <Trans>Upload from Library</Trans>
-              ) : (
-                <Trans>Upload from Files</Trans>
-              )}
-            </Menu.ItemText>
-            <Menu.ItemIcon icon={Library} />
-          </Menu.Item>
-        </Menu.Group>
-        {!!avatar && (
-          <>
-            <Menu.Divider />
-            <Menu.Group>
-              <Menu.Item
-                testID="changeAvatarRemoveBtn"
-                label={_(msg`Remove Avatar`)}
-                onPress={onRemoveAvatar}>
-                <Menu.ItemText>
-                  <Trans>Remove Avatar</Trans>
-                </Menu.ItemText>
-                <Menu.ItemIcon icon={Trash} />
-              </Menu.Item>
-            </Menu.Group>
-          </>
-        )}
-      </Menu.Outer>
-    </Menu.Root>
+      <EditImageDialog
+        control={editImageDialogControl}
+        image={rawImage}
+        onChange={onChangeEditImage}
+        aspectRatio={1}
+        circularCrop={circular}
+      />
+    </>
   )
 }
 EditableUserAvatar = memo(EditableUserAvatar)
@@ -440,7 +493,7 @@ let PreviewableUserAvatar = ({
 
   const onPress = React.useCallback(() => {
     onBeforePress?.()
-    precacheProfile(queryClient, profile)
+    unstableCacheProfileView(queryClient, profile)
   }, [profile, queryClient, onBeforePress])
 
   const avatarEl = (
@@ -494,15 +547,5 @@ const styles = StyleSheet.create({
     borderRadius: 12,
     alignItems: 'center',
     justifyContent: 'center',
-    backgroundColor: colors.gray5,
-  },
-  alertIconContainer: {
-    position: 'absolute',
-    right: 0,
-    bottom: 0,
-    borderRadius: 100,
-  },
-  alertIcon: {
-    color: colors.red3,
   },
 })
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index ab7f25b80..3600f5c24 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,35 +1,36 @@
-import React from 'react'
+import {useCallback, useState} from 'react'
 import {Pressable, StyleSheet, View} from 'react-native'
 import {Image} from 'expo-image'
 import {type ModerationUI} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {usePalette} from '#/lib/hooks/usePalette'
 import {
   useCameraPermission,
   usePhotoLibraryPermission,
 } from '#/lib/hooks/usePermissions'
-import {colors} from '#/lib/styles'
-import {useTheme} from '#/lib/ThemeContext'
+import {compressIfNeeded} from '#/lib/media/manip'
+import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
+import {type PickerImage} from '#/lib/media/picker.shared'
 import {logger} from '#/logger'
 import {isAndroid, isNative} from '#/platform/detection'
+import {
+  type ComposerImage,
+  compressImage,
+  createComposerImage,
+} from '#/state/gallery'
+import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
 import {EventStopper} from '#/view/com/util/EventStopper'
-import {tokens, useTheme as useAlfTheme} from '#/alf'
+import {atoms as a, tokens, useTheme} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
 import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
 import {
-  Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
-  Camera_Stroke2_Corner0_Rounded as Camera,
+  Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon,
+  Camera_Stroke2_Corner0_Rounded as CameraIcon,
 } from '#/components/icons/Camera'
-import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
-import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
 import * as Menu from '#/components/Menu'
-import {
-  openCamera,
-  openCropper,
-  openPicker,
-  type RNImage,
-} from '../../../lib/media/picker'
 
 export function UserBanner({
   type,
@@ -40,28 +41,30 @@ export function UserBanner({
   type?: 'labeler' | 'default'
   banner?: string | null
   moderation?: ModerationUI
-  onSelectNewBanner?: (img: RNImage | null) => void
+  onSelectNewBanner?: (img: PickerImage | null) => void
 }) {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const t = useAlfTheme()
+  const t = useTheme()
   const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
   const sheetWrapper = useSheetWrapper()
+  const [rawImage, setRawImage] = useState<ComposerImage | undefined>()
+  const editImageDialogControl = useDialogControl()
 
-  const onOpenCamera = React.useCallback(async () => {
+  const onOpenCamera = useCallback(async () => {
     if (!(await requestCameraAccessIfNeeded())) {
       return
     }
     onSelectNewBanner?.(
-      await openCamera({
-        aspect: [3, 1],
-      }),
+      await compressIfNeeded(
+        await openCamera({
+          aspect: [3, 1],
+        }),
+      ),
     )
   }, [onSelectNewBanner, requestCameraAccessIfNeeded])
 
-  const onOpenLibrary = React.useCallback(async () => {
+  const onOpenLibrary = useCallback(async () => {
     if (!(await requestPhotoAccessIfNeeded())) {
       return
     }
@@ -71,105 +74,141 @@ export function UserBanner({
     }
 
     try {
-      onSelectNewBanner?.(
-        await openCropper({
-          imageUri: items[0].path,
-          aspectRatio: 3 / 1,
-        }),
-      )
+      if (isNative) {
+        onSelectNewBanner?.(
+          await compressIfNeeded(
+            await openCropper({
+              imageUri: items[0].path,
+              aspectRatio: 3 / 1,
+            }),
+          ),
+        )
+      } else {
+        setRawImage(await createComposerImage(items[0]))
+        editImageDialogControl.open()
+      }
     } catch (e: any) {
       if (!String(e).includes('Canceled')) {
         logger.error('Failed to crop banner', {error: e})
       }
     }
-  }, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper])
+  }, [
+    onSelectNewBanner,
+    requestPhotoAccessIfNeeded,
+    sheetWrapper,
+    editImageDialogControl,
+  ])
 
-  const onRemoveBanner = React.useCallback(() => {
+  const onRemoveBanner = useCallback(() => {
     onSelectNewBanner?.(null)
   }, [onSelectNewBanner])
 
+  const onChangeEditImage = useCallback(
+    async (image: ComposerImage) => {
+      const compressed = await compressImage(image)
+      onSelectNewBanner?.(compressed)
+    },
+    [onSelectNewBanner],
+  )
+
   // setUserBanner is only passed as prop on the EditProfile component
   return onSelectNewBanner ? (
-    <EventStopper onKeyDown={true}>
-      <Menu.Root>
-        <Menu.Trigger label={_(msg`Edit avatar`)}>
-          {({props}) => (
-            <Pressable {...props} testID="changeBannerBtn">
-              {banner ? (
-                <Image
-                  testID="userBannerImage"
-                  style={styles.bannerImage}
-                  source={{uri: banner}}
-                  accessible={true}
-                  accessibilityIgnoresInvertColors
-                />
-              ) : (
+    <>
+      <EventStopper onKeyDown={true}>
+        <Menu.Root>
+          <Menu.Trigger label={_(msg`Edit avatar`)}>
+            {({props}) => (
+              <Pressable {...props} testID="changeBannerBtn">
+                {banner ? (
+                  <Image
+                    testID="userBannerImage"
+                    style={styles.bannerImage}
+                    source={{uri: banner}}
+                    accessible={true}
+                    accessibilityIgnoresInvertColors
+                  />
+                ) : (
+                  <View
+                    testID="userBannerFallback"
+                    style={[styles.bannerImage, t.atoms.bg_contrast_25]}
+                  />
+                )}
                 <View
-                  testID="userBannerFallback"
-                  style={[styles.bannerImage, t.atoms.bg_contrast_25]}
-                />
+                  style={[
+                    styles.editButtonContainer,
+                    t.atoms.bg_contrast_25,
+                    a.border,
+                    t.atoms.border_contrast_low,
+                  ]}>
+                  <CameraFilledIcon
+                    height={14}
+                    width={14}
+                    style={t.atoms.text}
+                  />
+                </View>
+              </Pressable>
+            )}
+          </Menu.Trigger>
+          <Menu.Outer showCancel>
+            <Menu.Group>
+              {isNative && (
+                <Menu.Item
+                  testID="changeBannerCameraBtn"
+                  label={_(msg`Upload from Camera`)}
+                  onPress={onOpenCamera}>
+                  <Menu.ItemText>
+                    <Trans>Upload from Camera</Trans>
+                  </Menu.ItemText>
+                  <Menu.ItemIcon icon={CameraIcon} />
+                </Menu.Item>
               )}
-              <View style={[styles.editButtonContainer, pal.btn]}>
-                <CameraFilled height={14} width={14} style={t.atoms.text} />
-              </View>
-            </Pressable>
-          )}
-        </Menu.Trigger>
-        <Menu.Outer showCancel>
-          <Menu.Group>
-            {isNative && (
+
               <Menu.Item
-                testID="changeBannerCameraBtn"
-                label={_(msg`Upload from Camera`)}
-                onPress={onOpenCamera}>
+                testID="changeBannerLibraryBtn"
+                label={_(msg`Upload from Library`)}
+                onPress={onOpenLibrary}>
                 <Menu.ItemText>
-                  <Trans>Upload from Camera</Trans>
+                  {isNative ? (
+                    <Trans>Upload from Library</Trans>
+                  ) : (
+                    <Trans>Upload from Files</Trans>
+                  )}
                 </Menu.ItemText>
-                <Menu.ItemIcon icon={Camera} />
+                <Menu.ItemIcon icon={LibraryIcon} />
               </Menu.Item>
+            </Menu.Group>
+            {!!banner && (
+              <>
+                <Menu.Divider />
+                <Menu.Group>
+                  <Menu.Item
+                    testID="changeBannerRemoveBtn"
+                    label={_(msg`Remove Banner`)}
+                    onPress={onRemoveBanner}>
+                    <Menu.ItemText>
+                      <Trans>Remove Banner</Trans>
+                    </Menu.ItemText>
+                    <Menu.ItemIcon icon={TrashIcon} />
+                  </Menu.Item>
+                </Menu.Group>
+              </>
             )}
+          </Menu.Outer>
+        </Menu.Root>
+      </EventStopper>
 
-            <Menu.Item
-              testID="changeBannerLibraryBtn"
-              label={_(msg`Upload from Library`)}
-              onPress={onOpenLibrary}>
-              <Menu.ItemText>
-                {isNative ? (
-                  <Trans>Upload from Library</Trans>
-                ) : (
-                  <Trans>Upload from Files</Trans>
-                )}
-              </Menu.ItemText>
-              <Menu.ItemIcon icon={Library} />
-            </Menu.Item>
-          </Menu.Group>
-          {!!banner && (
-            <>
-              <Menu.Divider />
-              <Menu.Group>
-                <Menu.Item
-                  testID="changeBannerRemoveBtn"
-                  label={_(msg`Remove Banner`)}
-                  onPress={onRemoveBanner}>
-                  <Menu.ItemText>
-                    <Trans>Remove Banner</Trans>
-                  </Menu.ItemText>
-                  <Menu.ItemIcon icon={Trash} />
-                </Menu.Item>
-              </Menu.Group>
-            </>
-          )}
-        </Menu.Outer>
-      </Menu.Root>
-    </EventStopper>
+      <EditImageDialog
+        control={editImageDialogControl}
+        image={rawImage}
+        onChange={onChangeEditImage}
+        aspectRatio={3}
+      />
+    </>
   ) : banner &&
     !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
     <Image
       testID="userBannerImage"
-      style={[
-        styles.bannerImage,
-        {backgroundColor: theme.palette.default.backgroundLight},
-      ]}
+      style={[styles.bannerImage, t.atoms.bg_contrast_25]}
       contentFit="cover"
       source={{uri: banner}}
       blurRadius={moderation?.blur ? 100 : 0}
@@ -197,7 +236,6 @@ const styles = StyleSheet.create({
     borderRadius: 12,
     alignItems: 'center',
     justifyContent: 'center',
-    backgroundColor: colors.gray5,
   },
   bannerImage: {
     width: '100%',