Compare commits

...

3 Commits

Author SHA1 Message Date
Samuel Newman ee7e3f866d theme color border 2024-10-29 18:20:27 +00:00
Samuel Newman db26f3b39e delete old modal 2024-10-29 17:02:33 +00:00
Samuel Newman c963f4ad5e imperative cropper dialog 2024-10-29 17:00:36 +00:00
14 changed files with 393 additions and 328 deletions
+2 -2
View File
@@ -30,8 +30,8 @@ export function Header({
t.atoms.border_contrast_medium,
t.atoms.bg,
web([
{borderRadiusTopLeft: a.rounded_md.borderRadius},
{borderRadiusTopRight: a.rounded_md.borderRadius},
{borderTopLeftRadius: a.rounded_md.borderRadius},
{borderTopRightRadius: a.rounded_md.borderRadius},
]),
style,
]}>
@@ -0,0 +1,30 @@
import React, {useCallback, useRef} from 'react'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {openCropperNative} from '#/lib/media/picker'
import {CropperOptions} from '#/lib/media/types'
export type CropImage = (opts: CropperOptions) => Promise<RNImage>
type CropImageCallback = (
opts: CropperOptions,
onComplete: CropperCallback,
) => void
type CropperCallback = (image: RNImage | null) => unknown
export function useImageCropperControl(): [
ref: React.Ref<{openCropper: CropImageCallback}>,
crop: CropImage,
] {
const ref = useRef<{openCropper: CropImageCallback}>(null)
const crop = useCallback((opts: CropperOptions) => {
return openCropperNative(opts)
}, [])
return [ref, crop] as const
}
export function CropImageDialog({}: {
controlRef: React.Ref<{openCropper: CropImageCallback}>
}) {
// web only
return null
}
@@ -0,0 +1,199 @@
import React, {useCallback, useImperativeHandle, useRef} from 'react'
import {Text, View} from 'react-native'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import ReactCrop, {PercentCrop} from 'react-image-crop'
import {CropperOptions} from '#/lib/media/types'
import {getDataUriSize} from '#/lib/media/util'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
export type CropImage = (opts: CropperOptions) => Promise<RNImage>
type CropImageCallback = (
opts: CropperOptions,
onComplete: CropperCallback,
) => void
type CropperCallback = (image: RNImage | null) => unknown
export function useImageCropperControl(): [
ref: React.Ref<{openCropper: CropImageCallback}>,
crop: CropImage,
] {
const ref = useRef<{openCropper: CropImageCallback}>(null)
const crop = useCallback((opts: CropperOptions) => {
return new Promise<RNImage>((resolve, reject) => {
if (!ref.current) {
console.error('ImageCropper error - ref is not connected to the dialog')
reject(new Error('Ref not connected'))
return
}
ref.current.openCropper(opts, image => {
if (image) {
resolve(image)
} else {
reject(new Error('User cancelled'))
}
})
})
}, [])
return [ref, crop] as const
}
export function CropImageDialog({
controlRef,
}: {
controlRef: React.Ref<{openCropper: CropImageCallback}>
}) {
const control = Dialog.useDialogControl()
const [opts, setOpts] = React.useState<CropperOptions | null>(null)
const callbackRef = useRef<CropperCallback | null>(null)
useImperativeHandle(controlRef, () => ({
openCropper: (opts, onComplete) => {
setOpts(opts)
callbackRef.current = onComplete
control.open()
},
}))
const onCrop = (image: RNImage) => {
if (callbackRef.current) {
callbackRef.current(image)
callbackRef.current = null
}
control.close()
}
return (
<Dialog.Outer
control={control}
onClose={() => {
// closed without saving
if (callbackRef.current) {
callbackRef.current(null)
callbackRef.current = null
}
// cleanup
setOpts(null)
}}>
<Dialog.Handle />
<DialogInner opts={opts} onCrop={onCrop} />
</Dialog.Outer>
)
}
function DialogInner({
opts,
onCrop,
}: {
opts: CropperOptions | null
onCrop: (image: RNImage) => void
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const imageRef = React.useRef<HTMLImageElement>(null)
const [crop, setCrop] = React.useState<PercentCrop>()
const t = useTheme()
if (!opts) {
control.close()
return null
}
const uri = opts.path
const aspect = opts.webAspectRatio
const circular = opts.webCircularCrop
const isEmpty = !crop || (crop.width || crop.height) === 0
const onPressDone = async () => {
const img = imageRef.current
if (!img) {
return
}
const actions = 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,
},
},
]
const result = await manipulateAsync(uri, actions, {
base64: true,
format: SaveFormat.JPEG,
})
onCrop({
path: result.uri,
mime: 'image/jpeg',
size: result.base64 !== undefined ? getDataUriSize(result.base64) : 0,
width: result.width,
height: result.height,
})
control.close()
}
return (
<Dialog.Inner label={_(msg`Crop image`)}>
<Dialog.Close />
<View style={[a.gap_xl]}>
<Text
style={[a.text_2xl, a.font_bold, a.leading_tight, a.mb_sm, a.mt_sm]}>
<Trans>Edit image</Trans>
</Text>
<View
style={[
a.mx_auto,
a.border,
a.overflow_hidden,
a.rounded_xs,
a.align_center,
t.atoms.border_contrast_medium,
]}>
<ReactCrop
aspect={aspect}
crop={crop}
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
circularCrop={circular}
className="ReactCrop--no-animate">
<img ref={imageRef} src={uri} style={{maxHeight: '75vh'}} />
</ReactCrop>
</View>
<View style={[a.flex_1, a.flex_row, a.gap_lg, a.justify_end]}>
<Button
label={_(msg`Cancel`)}
onPress={() => control.close()}
color="secondary"
size="small"
variant="solid">
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Done`)}
onPress={onPressDone}
color="primary"
size="small"
variant="solid">
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</View>
</View>
</Dialog.Inner>
)
}
+1 -1
View File
@@ -45,7 +45,7 @@ export async function openCamera(): Promise<RNImage> {
return await getFile()
}
export async function openCropper(opts: CropperOptions) {
export async function openCropperNative(opts: CropperOptions) {
const item = await openCropperFn({
...opts,
forceJpg: true, // ios only
+1 -1
View File
@@ -27,7 +27,7 @@ export async function openCamera(opts: CameraOpts): Promise<RNImage> {
}
}
export async function openCropper(opts: CropperOptions) {
export async function openCropperNative(opts: CropperOptions) {
const item = await openCropperFn({
...opts,
forceJpg: true, // ios only
+2 -22
View File
@@ -4,32 +4,12 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {CameraOpts, CropperOptions} from './types'
export {openPicker} from './picker.shared'
import {unstable__openModal} from '#/state/modals'
export async function openCamera(_opts: CameraOpts): Promise<RNImage> {
// const mediaType = opts.mediaType || 'photo' TODO
throw new Error('TODO')
}
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
// TODO handle more opts
return new Promise((resolve, reject) => {
unstable__openModal({
name: 'crop-image',
uri: opts.path,
dimensions:
opts.width && opts.height
? {width: opts.width, height: opts.height}
: undefined,
aspect: opts.webAspectRatio,
circular: opts.webCircularCrop,
onSelect: (img?: RNImage) => {
if (img) {
resolve(img)
} else {
reject(new Error('Canceled'))
}
},
})
})
export async function openCropperNative(_opts: CropperOptions) {
throw new Error('Native only: use CropImageDialog instead')
}
+2 -2
View File
@@ -11,7 +11,7 @@ import {useLingui} from '@lingui/react'
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
import {compressIfNeeded} from '#/lib/media/manip'
import {openCropper} from '#/lib/media/picker'
import {openCropperNative} from '#/lib/media/picker'
import {getDataUriSize} from '#/lib/media/util'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {logEvent, useGate} from '#/lib/statsig/statsig'
@@ -181,7 +181,7 @@ export function StepProfile() {
if (!image) return
if (!isWeb) {
image = await openCropper({
image = await openCropperNative({
mediaType: 'photo',
cropperCircleOverlay: true,
height: 1000,
+6 -3
View File
@@ -14,9 +14,9 @@ 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 {getDataUriSize} from '#/lib/media/util'
import {isIOS, isNative} from '#/platform/detection'
import {CropImage} from '#/components/dialogs/CropImageDialog'
export type ImageTransformation = {
crop?: ActionCrop['crop']
@@ -117,7 +117,10 @@ export async function pasteImage(
}
}
export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
export async function cropImage(
img: ComposerImage,
cropImage: CropImage,
): Promise<ComposerImage> {
if (!isNative) {
return img
}
@@ -136,7 +139,7 @@ export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
// @todo: we're always passing the original image here, does image-cropper
// allows for setting initial crop dimensions? -mary
try {
const cropped = await openCropper({
const cropped = await cropImage({
mediaType: 'photo',
path: source.path,
freeStyleCropEnabled: true,
-13
View File
@@ -1,5 +1,4 @@
import React from 'react'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -29,15 +28,6 @@ export interface ListAddRemoveUsersModal {
) => void
}
export interface CropImageModal {
name: 'crop-image'
uri: string
dimensions?: {width: number; height: number}
aspect?: number
circular?: boolean
onSelect: (img?: RNImage) => void
}
export interface DeleteAccountModal {
name: 'delete-account'
}
@@ -111,9 +101,6 @@ export type Modal =
| UserAddRemoveListsModal
| ListAddRemoveUsersModal
// Posts
| CropImageModal
// Bluesky access
| WaitlistModal
| InviteCodesModal
+2 -1
View File
@@ -14,6 +14,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {openCropperNative} from '#/lib/media/picker'
import {Dimensions} from '#/lib/media/types'
import {colors, s} from '#/lib/styles'
import {isNative} from '#/platform/detection'
@@ -145,7 +146,7 @@ const GalleryItem = ({
const onImageEdit = () => {
if (isNative) {
cropImage(image).then(next => {
cropImage(image, openCropperNative).then(next => {
onChange(next)
})
} else {
-145
View File
@@ -1,145 +0,0 @@
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,
},
})
-6
View File
@@ -12,7 +12,6 @@ import * as ChangeEmailModal from './ChangeEmail'
import * as ChangeHandleModal from './ChangeHandle'
import * as ChangePasswordModal from './ChangePassword'
import * as CreateOrEditListModal from './CreateOrEditList'
import * as CropImageModal from './CropImage.web'
import * as DeleteAccountModal from './DeleteAccount'
import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
@@ -50,9 +49,6 @@ function Modal({modal}: {modal: ModalIface}) {
}
const onPressMask = () => {
if (modal.name === 'crop-image') {
return // dont close on mask presses during crop
}
closeModal()
}
const onInnerPress = () => {
@@ -67,8 +63,6 @@ function Modal({modal}: {modal: ModalIface}) {
element = <UserAddRemoveLists.Component {...modal} />
} else if (modal.name === 'list-add-remove-users') {
element = <ListAddUserModal.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 === 'change-handle') {
+71 -63
View File
@@ -13,6 +13,7 @@ import {
useCameraPermission,
usePhotoLibraryPermission,
} from '#/lib/hooks/usePermissions'
import {openCamera, openPicker} from '#/lib/media/picker'
import {makeProfileLink} from '#/lib/routes/links'
import {colors} from '#/lib/styles'
import {logger} from '#/logger'
@@ -21,6 +22,10 @@ import {precacheProfile} from '#/state/queries/profile'
import {HighPriorityImage} from '#/view/com/util/images/Image'
import {tokens, useTheme} from '#/alf'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {
CropImageDialog,
useImageCropperControl,
} from '#/components/dialogs/CropImageDialog'
import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
Camera_Stroke2_Corner0_Rounded as Camera,
@@ -31,7 +36,6 @@ import {Link} from '#/components/Link'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as Menu from '#/components/Menu'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
@@ -273,6 +277,7 @@ let EditableUserAvatar = ({
const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const sheetWrapper = useSheetWrapper()
const [cropControlRef, cropImage] = useImageCropperControl()
const aviStyle = useMemo(() => {
if (type === 'algo' || type === 'list') {
@@ -319,7 +324,7 @@ let EditableUserAvatar = ({
}
try {
const croppedImage = await openCropper({
const croppedImage = await cropImage({
mediaType: 'photo',
cropperCircleOverlay: true,
height: 1000,
@@ -336,79 +341,82 @@ let EditableUserAvatar = ({
logger.error('Failed to crop banner', {error: e})
}
}
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper])
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper, cropImage])
const onRemoveAvatar = React.useCallback(() => {
onSelectNewAvatar(null)
}, [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} />
)}
<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}>
<Menu.ItemText>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Camera} />
</Menu.Item>
)}
<Menu.Item
testID="changeAvatarLibraryBtn"
label={_(msg`Upload from Library`)}
onPress={onOpenLibrary}>
<Menu.ItemText>
{isNative ? (
<Trans>Upload from Library</Trans>
<>
<Menu.Root>
<Menu.Trigger label={_(msg`Edit avatar`)}>
{({props}) => (
<Pressable {...props} testID="changeAvatarBtn">
{avatar ? (
<HighPriorityImage
testID="userAvatarImage"
style={aviStyle}
source={{uri: avatar}}
accessibilityRole="image"
/>
) : (
<Trans>Upload from Files</Trans>
<DefaultAvatar type={type} size={size} />
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Library} />
</Menu.Item>
</Menu.Group>
{!!avatar && (
<>
<Menu.Divider />
<Menu.Group>
<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="changeAvatarRemoveBtn"
label={_(`Remove Avatar`)}
onPress={onRemoveAvatar}>
testID="changeAvatarCameraBtn"
label={_(msg`Upload from Camera`)}
onPress={onOpenCamera}>
<Menu.ItemText>
<Trans>Remove Avatar</Trans>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} />
<Menu.ItemIcon icon={Camera} />
</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={_(`Remove Avatar`)}
onPress={onRemoveAvatar}>
<Menu.ItemText>
<Trans>Remove Avatar</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} />
</Menu.Item>
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
<CropImageDialog controlRef={cropControlRef} />
</>
)
}
EditableUserAvatar = memo(EditableUserAvatar)
+77 -69
View File
@@ -11,6 +11,7 @@ import {
useCameraPermission,
usePhotoLibraryPermission,
} from '#/lib/hooks/usePermissions'
import {openCamera, openPicker} from '#/lib/media/picker'
import {colors} from '#/lib/styles'
import {useTheme} from '#/lib/ThemeContext'
import {logger} from '#/logger'
@@ -18,6 +19,10 @@ import {isAndroid, isNative} from '#/platform/detection'
import {EventStopper} from '#/view/com/util/EventStopper'
import {tokens, useTheme as useAlfTheme} from '#/alf'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {
CropImageDialog,
useImageCropperControl,
} from '#/components/dialogs/CropImageDialog'
import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
Camera_Stroke2_Corner0_Rounded as Camera,
@@ -25,7 +30,6 @@ import {
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import * as Menu from '#/components/Menu'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
export function UserBanner({
type,
@@ -45,6 +49,7 @@ export function UserBanner({
const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const sheetWrapper = useSheetWrapper()
const [cropControlRef, cropImage] = useImageCropperControl()
const onOpenCamera = React.useCallback(async () => {
if (!(await requestCameraAccessIfNeeded())) {
@@ -69,7 +74,7 @@ export function UserBanner({
try {
onSelectNewBanner?.(
await openCropper({
await cropImage({
mediaType: 'photo',
path: items[0].path,
width: 3000,
@@ -82,7 +87,7 @@ export function UserBanner({
logger.error('Failed to crop banner', {error: e})
}
}
}, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper])
}, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper, cropImage])
const onRemoveBanner = React.useCallback(() => {
onSelectNewBanner?.(null)
@@ -90,78 +95,81 @@ export function UserBanner({
// 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
/>
) : (
<View
testID="userBannerFallback"
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
/>
)}
<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}>
<Menu.ItemText>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Camera} />
</Menu.Item>
)}
<Menu.Item
testID="changeBannerLibraryBtn"
label={_(msg`Upload from Library`)}
onPress={onOpenLibrary}>
<Menu.ItemText>
{isNative ? (
<Trans>Upload from Library</Trans>
<>
<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
/>
) : (
<Trans>Upload from Files</Trans>
<View
testID="userBannerFallback"
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
/>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Library} />
</Menu.Item>
</Menu.Group>
{!!banner && (
<>
<Menu.Divider />
<Menu.Group>
<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="changeBannerRemoveBtn"
label={_(`Remove Banner`)}
onPress={onRemoveBanner}>
testID="changeBannerCameraBtn"
label={_(msg`Upload from Camera`)}
onPress={onOpenCamera}>
<Menu.ItemText>
<Trans>Remove Banner</Trans>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} />
<Menu.ItemIcon icon={Camera} />
</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={_(`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>
<CropImageDialog controlRef={cropControlRef} />
</>
) : banner &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<Image