imperative cropper dialog
This commit is contained in:
@@ -30,8 +30,8 @@ export function Header({
|
|||||||
t.atoms.border_contrast_medium,
|
t.atoms.border_contrast_medium,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
web([
|
web([
|
||||||
{borderRadiusTopLeft: a.rounded_md.borderRadius},
|
{borderTopLeftRadius: a.rounded_md.borderRadius},
|
||||||
{borderRadiusTopRight: a.rounded_md.borderRadius},
|
{borderTopRightRadius: a.rounded_md.borderRadius},
|
||||||
]),
|
]),
|
||||||
style,
|
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,196 @@
|
|||||||
|
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} 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>()
|
||||||
|
|
||||||
|
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.pb_sm]}>
|
||||||
|
<Trans>Edit image</Trans>
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.mx_auto,
|
||||||
|
{borderWidth: 1},
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.rounded_xs,
|
||||||
|
a.align_center,
|
||||||
|
]}>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export async function openCamera(): Promise<RNImage> {
|
|||||||
return await getFile()
|
return await getFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCropper(opts: CropperOptions) {
|
export async function openCropperNative(opts: CropperOptions) {
|
||||||
const item = await openCropperFn({
|
const item = await openCropperFn({
|
||||||
...opts,
|
...opts,
|
||||||
forceJpg: true, // ios only
|
forceJpg: true, // ios only
|
||||||
|
|||||||
@@ -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({
|
const item = await openCropperFn({
|
||||||
...opts,
|
...opts,
|
||||||
forceJpg: true, // ios only
|
forceJpg: true, // ios only
|
||||||
|
|||||||
@@ -4,32 +4,12 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
|
|||||||
|
|
||||||
import {CameraOpts, CropperOptions} from './types'
|
import {CameraOpts, CropperOptions} from './types'
|
||||||
export {openPicker} from './picker.shared'
|
export {openPicker} from './picker.shared'
|
||||||
import {unstable__openModal} from '#/state/modals'
|
|
||||||
|
|
||||||
export async function openCamera(_opts: CameraOpts): Promise<RNImage> {
|
export async function openCamera(_opts: CameraOpts): Promise<RNImage> {
|
||||||
// const mediaType = opts.mediaType || 'photo' TODO
|
// const mediaType = opts.mediaType || 'photo' TODO
|
||||||
throw new Error('TODO')
|
throw new Error('TODO')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
|
export async function openCropperNative(_opts: CropperOptions) {
|
||||||
// TODO handle more opts
|
throw new Error('Native only: use CropImageDialog instead')
|
||||||
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'))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||||
import {compressIfNeeded} from '#/lib/media/manip'
|
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 {getDataUriSize} from '#/lib/media/util'
|
||||||
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
||||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||||
@@ -181,7 +181,7 @@ export function StepProfile() {
|
|||||||
if (!image) return
|
if (!image) return
|
||||||
|
|
||||||
if (!isWeb) {
|
if (!isWeb) {
|
||||||
image = await openCropper({
|
image = await openCropperNative({
|
||||||
mediaType: 'photo',
|
mediaType: 'photo',
|
||||||
cropperCircleOverlay: true,
|
cropperCircleOverlay: true,
|
||||||
height: 1000,
|
height: 1000,
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import {nanoid} from 'nanoid/non-secure'
|
|||||||
|
|
||||||
import {POST_IMG_MAX} from '#/lib/constants'
|
import {POST_IMG_MAX} from '#/lib/constants'
|
||||||
import {getImageDim} from '#/lib/media/manip'
|
import {getImageDim} from '#/lib/media/manip'
|
||||||
import {openCropper} from '#/lib/media/picker'
|
|
||||||
import {getDataUriSize} from '#/lib/media/util'
|
import {getDataUriSize} from '#/lib/media/util'
|
||||||
import {isIOS, isNative} from '#/platform/detection'
|
import {isIOS, isNative} from '#/platform/detection'
|
||||||
|
import {CropImage} from '#/components/dialogs/CropImageDialog'
|
||||||
|
|
||||||
export type ImageTransformation = {
|
export type ImageTransformation = {
|
||||||
crop?: ActionCrop['crop']
|
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) {
|
if (!isNative) {
|
||||||
return img
|
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
|
// @todo: we're always passing the original image here, does image-cropper
|
||||||
// allows for setting initial crop dimensions? -mary
|
// allows for setting initial crop dimensions? -mary
|
||||||
try {
|
try {
|
||||||
const cropped = await openCropper({
|
const cropped = await cropImage({
|
||||||
mediaType: 'photo',
|
mediaType: 'photo',
|
||||||
path: source.path,
|
path: source.path,
|
||||||
freeStyleCropEnabled: true,
|
freeStyleCropEnabled: true,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
|
import {openCropperNative} from '#/lib/media/picker'
|
||||||
import {Dimensions} from '#/lib/media/types'
|
import {Dimensions} from '#/lib/media/types'
|
||||||
import {colors, s} from '#/lib/styles'
|
import {colors, s} from '#/lib/styles'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
@@ -145,7 +146,7 @@ const GalleryItem = ({
|
|||||||
|
|
||||||
const onImageEdit = () => {
|
const onImageEdit = () => {
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
cropImage(image).then(next => {
|
cropImage(image, openCropperNative).then(next => {
|
||||||
onChange(next)
|
onChange(next)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useCameraPermission,
|
useCameraPermission,
|
||||||
usePhotoLibraryPermission,
|
usePhotoLibraryPermission,
|
||||||
} from '#/lib/hooks/usePermissions'
|
} from '#/lib/hooks/usePermissions'
|
||||||
|
import {openCamera, openPicker} from '#/lib/media/picker'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {colors} from '#/lib/styles'
|
import {colors} from '#/lib/styles'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
@@ -21,6 +22,10 @@ import {precacheProfile} from '#/state/queries/profile'
|
|||||||
import {HighPriorityImage} from '#/view/com/util/images/Image'
|
import {HighPriorityImage} from '#/view/com/util/images/Image'
|
||||||
import {tokens, useTheme} from '#/alf'
|
import {tokens, useTheme} from '#/alf'
|
||||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||||
|
import {
|
||||||
|
CropImageDialog,
|
||||||
|
useImageCropperControl,
|
||||||
|
} from '#/components/dialogs/CropImageDialog'
|
||||||
import {
|
import {
|
||||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
||||||
Camera_Stroke2_Corner0_Rounded as Camera,
|
Camera_Stroke2_Corner0_Rounded as Camera,
|
||||||
@@ -31,7 +36,6 @@ import {Link} from '#/components/Link'
|
|||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
|
|
||||||
|
|
||||||
export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
|
export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
|
||||||
|
|
||||||
@@ -273,6 +277,7 @@ let EditableUserAvatar = ({
|
|||||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
const sheetWrapper = useSheetWrapper()
|
const sheetWrapper = useSheetWrapper()
|
||||||
|
const [cropControlRef, cropImage] = useImageCropperControl()
|
||||||
|
|
||||||
const aviStyle = useMemo(() => {
|
const aviStyle = useMemo(() => {
|
||||||
if (type === 'algo' || type === 'list') {
|
if (type === 'algo' || type === 'list') {
|
||||||
@@ -319,7 +324,7 @@ let EditableUserAvatar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const croppedImage = await openCropper({
|
const croppedImage = await cropImage({
|
||||||
mediaType: 'photo',
|
mediaType: 'photo',
|
||||||
cropperCircleOverlay: true,
|
cropperCircleOverlay: true,
|
||||||
height: 1000,
|
height: 1000,
|
||||||
@@ -336,79 +341,82 @@ let EditableUserAvatar = ({
|
|||||||
logger.error('Failed to crop banner', {error: e})
|
logger.error('Failed to crop banner', {error: e})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper])
|
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper, cropImage])
|
||||||
|
|
||||||
const onRemoveAvatar = React.useCallback(() => {
|
const onRemoveAvatar = React.useCallback(() => {
|
||||||
onSelectNewAvatar(null)
|
onSelectNewAvatar(null)
|
||||||
}, [onSelectNewAvatar])
|
}, [onSelectNewAvatar])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu.Root>
|
<>
|
||||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
<Menu.Root>
|
||||||
{({props}) => (
|
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||||
<Pressable {...props} testID="changeAvatarBtn">
|
{({props}) => (
|
||||||
{avatar ? (
|
<Pressable {...props} testID="changeAvatarBtn">
|
||||||
<HighPriorityImage
|
{avatar ? (
|
||||||
testID="userAvatarImage"
|
<HighPriorityImage
|
||||||
style={aviStyle}
|
testID="userAvatarImage"
|
||||||
source={{uri: avatar}}
|
style={aviStyle}
|
||||||
accessibilityRole="image"
|
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>
|
|
||||||
) : (
|
) : (
|
||||||
<Trans>Upload from Files</Trans>
|
<DefaultAvatar type={type} size={size} />
|
||||||
)}
|
)}
|
||||||
</Menu.ItemText>
|
<View style={[styles.editButtonContainer, pal.btn]}>
|
||||||
<Menu.ItemIcon icon={Library} />
|
<CameraFilled height={14} width={14} style={t.atoms.text} />
|
||||||
</Menu.Item>
|
</View>
|
||||||
</Menu.Group>
|
</Pressable>
|
||||||
{!!avatar && (
|
)}
|
||||||
<>
|
</Menu.Trigger>
|
||||||
<Menu.Divider />
|
<Menu.Outer showCancel>
|
||||||
<Menu.Group>
|
<Menu.Group>
|
||||||
|
{isNative && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="changeAvatarRemoveBtn"
|
testID="changeAvatarCameraBtn"
|
||||||
label={_(`Remove Avatar`)}
|
label={_(msg`Upload from Camera`)}
|
||||||
onPress={onRemoveAvatar}>
|
onPress={onOpenCamera}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
<Trans>Remove Avatar</Trans>
|
<Trans>Upload from Camera</Trans>
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Trash} />
|
<Menu.ItemIcon icon={Camera} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Group>
|
)}
|
||||||
</>
|
|
||||||
)}
|
<Menu.Item
|
||||||
</Menu.Outer>
|
testID="changeAvatarLibraryBtn"
|
||||||
</Menu.Root>
|
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)
|
EditableUserAvatar = memo(EditableUserAvatar)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
useCameraPermission,
|
useCameraPermission,
|
||||||
usePhotoLibraryPermission,
|
usePhotoLibraryPermission,
|
||||||
} from '#/lib/hooks/usePermissions'
|
} from '#/lib/hooks/usePermissions'
|
||||||
|
import {openCamera, openPicker} from '#/lib/media/picker'
|
||||||
import {colors} from '#/lib/styles'
|
import {colors} from '#/lib/styles'
|
||||||
import {useTheme} from '#/lib/ThemeContext'
|
import {useTheme} from '#/lib/ThemeContext'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
@@ -18,6 +19,10 @@ import {isAndroid, isNative} from '#/platform/detection'
|
|||||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||||
import {tokens, useTheme as useAlfTheme} from '#/alf'
|
import {tokens, useTheme as useAlfTheme} from '#/alf'
|
||||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||||
|
import {
|
||||||
|
CropImageDialog,
|
||||||
|
useImageCropperControl,
|
||||||
|
} from '#/components/dialogs/CropImageDialog'
|
||||||
import {
|
import {
|
||||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
||||||
Camera_Stroke2_Corner0_Rounded as Camera,
|
Camera_Stroke2_Corner0_Rounded as Camera,
|
||||||
@@ -25,7 +30,6 @@ import {
|
|||||||
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
|
|
||||||
|
|
||||||
export function UserBanner({
|
export function UserBanner({
|
||||||
type,
|
type,
|
||||||
@@ -45,6 +49,7 @@ export function UserBanner({
|
|||||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
const sheetWrapper = useSheetWrapper()
|
const sheetWrapper = useSheetWrapper()
|
||||||
|
const [cropControlRef, cropImage] = useImageCropperControl()
|
||||||
|
|
||||||
const onOpenCamera = React.useCallback(async () => {
|
const onOpenCamera = React.useCallback(async () => {
|
||||||
if (!(await requestCameraAccessIfNeeded())) {
|
if (!(await requestCameraAccessIfNeeded())) {
|
||||||
@@ -69,7 +74,7 @@ export function UserBanner({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
onSelectNewBanner?.(
|
onSelectNewBanner?.(
|
||||||
await openCropper({
|
await cropImage({
|
||||||
mediaType: 'photo',
|
mediaType: 'photo',
|
||||||
path: items[0].path,
|
path: items[0].path,
|
||||||
width: 3000,
|
width: 3000,
|
||||||
@@ -82,7 +87,7 @@ export function UserBanner({
|
|||||||
logger.error('Failed to crop banner', {error: e})
|
logger.error('Failed to crop banner', {error: e})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper])
|
}, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper, cropImage])
|
||||||
|
|
||||||
const onRemoveBanner = React.useCallback(() => {
|
const onRemoveBanner = React.useCallback(() => {
|
||||||
onSelectNewBanner?.(null)
|
onSelectNewBanner?.(null)
|
||||||
@@ -90,78 +95,81 @@ export function UserBanner({
|
|||||||
|
|
||||||
// setUserBanner is only passed as prop on the EditProfile component
|
// setUserBanner is only passed as prop on the EditProfile component
|
||||||
return onSelectNewBanner ? (
|
return onSelectNewBanner ? (
|
||||||
<EventStopper onKeyDown={true}>
|
<>
|
||||||
<Menu.Root>
|
<EventStopper onKeyDown={true}>
|
||||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
<Menu.Root>
|
||||||
{({props}) => (
|
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||||
<Pressable {...props} testID="changeBannerBtn">
|
{({props}) => (
|
||||||
{banner ? (
|
<Pressable {...props} testID="changeBannerBtn">
|
||||||
<Image
|
{banner ? (
|
||||||
testID="userBannerImage"
|
<Image
|
||||||
style={styles.bannerImage}
|
testID="userBannerImage"
|
||||||
source={{uri: banner}}
|
style={styles.bannerImage}
|
||||||
accessible={true}
|
source={{uri: banner}}
|
||||||
accessibilityIgnoresInvertColors
|
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>
|
|
||||||
) : (
|
) : (
|
||||||
<Trans>Upload from Files</Trans>
|
<View
|
||||||
|
testID="userBannerFallback"
|
||||||
|
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Menu.ItemText>
|
<View style={[styles.editButtonContainer, pal.btn]}>
|
||||||
<Menu.ItemIcon icon={Library} />
|
<CameraFilled height={14} width={14} style={t.atoms.text} />
|
||||||
</Menu.Item>
|
</View>
|
||||||
</Menu.Group>
|
</Pressable>
|
||||||
{!!banner && (
|
)}
|
||||||
<>
|
</Menu.Trigger>
|
||||||
<Menu.Divider />
|
<Menu.Outer showCancel>
|
||||||
<Menu.Group>
|
<Menu.Group>
|
||||||
|
{isNative && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="changeBannerRemoveBtn"
|
testID="changeBannerCameraBtn"
|
||||||
label={_(`Remove Banner`)}
|
label={_(msg`Upload from Camera`)}
|
||||||
onPress={onRemoveBanner}>
|
onPress={onOpenCamera}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
<Trans>Remove Banner</Trans>
|
<Trans>Upload from Camera</Trans>
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Trash} />
|
<Menu.ItemIcon icon={Camera} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Group>
|
)}
|
||||||
</>
|
|
||||||
)}
|
<Menu.Item
|
||||||
</Menu.Outer>
|
testID="changeBannerLibraryBtn"
|
||||||
</Menu.Root>
|
label={_(msg`Upload from Library`)}
|
||||||
</EventStopper>
|
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 &&
|
) : banner &&
|
||||||
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
|
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
Reference in New Issue
Block a user