diff --git a/src/components/Dialog/shared.tsx b/src/components/Dialog/shared.tsx index 6f9bc26781..d58a453bf6 100644 --- a/src/components/Dialog/shared.tsx +++ b/src/components/Dialog/shared.tsx @@ -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, ]}> diff --git a/src/components/dialogs/CropImageDialog.tsx b/src/components/dialogs/CropImageDialog.tsx new file mode 100644 index 0000000000..952859bde2 --- /dev/null +++ b/src/components/dialogs/CropImageDialog.tsx @@ -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 +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 +} diff --git a/src/components/dialogs/CropImageDialog.web.tsx b/src/components/dialogs/CropImageDialog.web.tsx new file mode 100644 index 0000000000..06a27bae9c --- /dev/null +++ b/src/components/dialogs/CropImageDialog.web.tsx @@ -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 +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((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(null) + const callbackRef = useRef(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 ( + { + // closed without saving + if (callbackRef.current) { + callbackRef.current(null) + callbackRef.current = null + } + // cleanup + setOpts(null) + }}> + + + + ) +} + +function DialogInner({ + opts, + onCrop, +}: { + opts: CropperOptions | null + onCrop: (image: RNImage) => void +}) { + const control = Dialog.useDialogContext() + const {_} = useLingui() + const imageRef = React.useRef(null) + const [crop, setCrop] = React.useState() + + 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 ( + + + + + Edit image + + + setCrop(percentCrop)} + circularCrop={circular} + className="ReactCrop--no-animate"> + + + + + + + + + + ) +} diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index fc6fcde45e..9ae74ced9b 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -45,7 +45,7 @@ export async function openCamera(): Promise { return await getFile() } -export async function openCropper(opts: CropperOptions) { +export async function openCropperNative(opts: CropperOptions) { const item = await openCropperFn({ ...opts, forceJpg: true, // ios only diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx index 37e01e67f9..80c25202a8 100644 --- a/src/lib/media/picker.tsx +++ b/src/lib/media/picker.tsx @@ -27,7 +27,7 @@ export async function openCamera(opts: CameraOpts): Promise { } } -export async function openCropper(opts: CropperOptions) { +export async function openCropperNative(opts: CropperOptions) { const item = await openCropperFn({ ...opts, forceJpg: true, // ios only diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index a53ffc9614..23402a826e 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -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 { // const mediaType = opts.mediaType || 'photo' TODO throw new Error('TODO') } -export async function openCropper(opts: CropperOptions): Promise { - // 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') } diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 73472ec332..b28468aa7c 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -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, diff --git a/src/state/gallery.ts b/src/state/gallery.ts index f4c8b712ef..d2ce38b233 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -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 { +export async function cropImage( + img: ComposerImage, + cropImage: CropImage, +): Promise { if (!isNative) { return img } @@ -136,7 +139,7 @@ export async function cropImage(img: ComposerImage): Promise { // @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, diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 5ff7042bc1..ae26225f64 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -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 { diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index dbd68f8ef5..9fa97b3d08 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -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 ( - - - {({props}) => ( - - {avatar ? ( - - ) : ( - - )} - - - - - )} - - - - {isNative && ( - - - Upload from Camera - - - - )} - - - - {isNative ? ( - Upload from Library + <> + + + {({props}) => ( + + {avatar ? ( + ) : ( - Upload from Files + )} - - - - - {!!avatar && ( - <> - - + + + + + )} + + + + {isNative && ( + testID="changeAvatarCameraBtn" + label={_(msg`Upload from Camera`)} + onPress={onOpenCamera}> - Remove Avatar + Upload from Camera - + - - - )} - - + )} + + + + {isNative ? ( + Upload from Library + ) : ( + Upload from Files + )} + + + + + {!!avatar && ( + <> + + + + + Remove Avatar + + + + + + )} + + + + ) } EditableUserAvatar = memo(EditableUserAvatar) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 7e71a04e9f..b4460881b8 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -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 ? ( - - - - {({props}) => ( - - {banner ? ( - - ) : ( - - )} - - - - - )} - - - - {isNative && ( - - - Upload from Camera - - - - )} - - - - {isNative ? ( - Upload from Library + <> + + + + {({props}) => ( + + {banner ? ( + ) : ( - Upload from Files + )} - - - - - {!!banner && ( - <> - - + + + + + )} + + + + {isNative && ( + testID="changeBannerCameraBtn" + label={_(msg`Upload from Camera`)} + onPress={onOpenCamera}> - Remove Banner + Upload from Camera - + - - - )} - - - + )} + + + + {isNative ? ( + Upload from Library + ) : ( + Upload from Files + )} + + + + + {!!banner && ( + <> + + + + + Remove Banner + + + + + + )} + + + + + ) : banner && !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (