Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee7e3f866d | |||
| db26f3b39e | |||
| c963f4ad5e |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user