diff options
Diffstat (limited to 'src/view/com')
-rw-r--r-- | src/view/com/composer/photos/EditImageDialog.tsx | 10 | ||||
-rw-r--r-- | src/view/com/composer/photos/EditImageDialog.web.tsx | 185 | ||||
-rw-r--r-- | src/view/com/modals/CreateOrEditList.tsx | 20 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 6 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 11 | ||||
-rw-r--r-- | src/view/com/util/EventStopper.tsx | 4 | ||||
-rw-r--r-- | src/view/com/util/UserAvatar.tsx | 251 | ||||
-rw-r--r-- | src/view/com/util/UserBanner.tsx | 240 |
8 files changed, 442 insertions, 285 deletions
diff --git a/src/view/com/composer/photos/EditImageDialog.tsx b/src/view/com/composer/photos/EditImageDialog.tsx index 4263587fd..9799f7b82 100644 --- a/src/view/com/composer/photos/EditImageDialog.tsx +++ b/src/view/com/composer/photos/EditImageDialog.tsx @@ -1,12 +1,14 @@ -import React from 'react' +import type React from 'react' -import {ComposerImage} from '#/state/gallery' -import * as Dialog from '#/components/Dialog' +import {type ComposerImage} from '#/state/gallery' +import type * as Dialog from '#/components/Dialog' export type EditImageDialogProps = { control: Dialog.DialogOuterProps['control'] - image: ComposerImage + image?: ComposerImage onChange: (next: ComposerImage) => void + aspectRatio?: number + circularCrop?: boolean } export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => { diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx index ebe528abc..9a170736a 100644 --- a/src/view/com/composer/photos/EditImageDialog.web.tsx +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -1,43 +1,130 @@ import 'react-image-crop/dist/ReactCrop.css' -import React from 'react' +import {useCallback, useImperativeHandle, useRef, useState} from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import ReactCrop, {PercentCrop} from 'react-image-crop' +import ReactCrop, {type PercentCrop} from 'react-image-crop' import { - ImageSource, - ImageTransformation, + type ImageSource, + type ImageTransformation, manipulateImage, } from '#/state/gallery' -import {atoms as a} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {Text} from '#/components/Typography' -import {EditImageDialogProps} from './EditImageDialog' +import {Loader} from '#/components/Loader' +import {type EditImageDialogProps} from './EditImageDialog' -export const EditImageDialog = (props: EditImageDialogProps) => { +export function EditImageDialog(props: EditImageDialogProps) { return ( <Dialog.Outer control={props.control}> <Dialog.Handle /> - <EditImageInner key={props.image.source.id} {...props} /> + <DialogInner {...props} /> </Dialog.Outer> ) } -const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { +function DialogInner({ + control, + image, + onChange, + circularCrop, + aspectRatio, +}: EditImageDialogProps) { const {_} = useLingui() + const [pending, setPending] = useState(false) + const ref = useRef<{save: () => Promise<void>}>(null) + + const cancelButton = useCallback( + () => ( + <Button + label={_(msg`Cancel`)} + disabled={pending} + onPress={() => control.close()} + size="small" + color="primary" + variant="ghost" + style={[a.rounded_full]} + testID="cropImageCancelBtn"> + <ButtonText style={[a.text_md]}> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + ), + [control, _, pending], + ) + + const saveButton = useCallback( + () => ( + <Button + label={_(msg`Save`)} + onPress={async () => { + setPending(true) + await ref.current?.save() + setPending(false) + }} + disabled={pending} + size="small" + color="primary" + variant="ghost" + style={[a.rounded_full]} + testID="cropImageSaveBtn"> + <ButtonText style={[a.text_md]}> + <Trans>Save</Trans> + </ButtonText> + {pending && <ButtonIcon icon={Loader} />} + </Button> + ), + [_, pending], + ) + + return ( + <Dialog.Inner + label={_(msg`Edit image`)} + header={ + <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}> + <Dialog.HeaderText> + <Trans>Edit image</Trans> + </Dialog.HeaderText> + </Dialog.Header> + }> + {image && ( + <EditImageInner + saveRef={ref} + key={image.source.id} + image={image} + onChange={onChange} + circularCrop={circularCrop} + aspectRatio={aspectRatio} + /> + )} + </Dialog.Inner> + ) +} + +function EditImageInner({ + image, + onChange, + saveRef, + circularCrop = false, + aspectRatio, +}: Required<Pick<EditImageDialogProps, 'image'>> & + Omit<EditImageDialogProps, 'control' | 'image'> & { + saveRef: React.RefObject<{save: () => Promise<void>}> + }) { + const t = useTheme() + const [isDragging, setIsDragging] = useState(false) + const {_} = useLingui() + const control = Dialog.useDialogContext() const source = image.source const initialCrop = getInitialCrop(source, image.manips) - const [crop, setCrop] = React.useState(initialCrop) - - const isEmpty = !crop || (crop.width || crop.height) === 0 - const isNew = initialCrop ? true : !isEmpty + const [crop, setCrop] = useState(initialCrop) - const onPressSubmit = React.useCallback(async () => { + const onPressSubmit = useCallback(async () => { const result = await manipulateImage(image, { crop: crop && (crop.width || crop.height) !== 0 @@ -50,41 +137,43 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { : undefined, }) - onChange(result) - control.close() + control.close(() => { + onChange(result) + }) }, [crop, image, source, control, onChange]) + useImperativeHandle( + saveRef, + () => ({ + save: onPressSubmit, + }), + [onPressSubmit], + ) + return ( - <Dialog.Inner label={_(msg`Edit image`)}> - <Dialog.Close /> - - <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}> - <Trans>Edit image</Trans> - </Text> - - <View style={[a.align_center]}> - <ReactCrop - crop={crop} - onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)} - className="ReactCrop--no-animate"> - <img src={source.path} style={{maxHeight: `50vh`}} /> - </ReactCrop> - </View> - - <View style={[a.mt_md, a.gap_md]}> - <Button - disabled={!isNew} - label={_(msg`Save`)} - size="large" - color="primary" - variant="solid" - onPress={onPressSubmit}> - <ButtonText> - <Trans>Save</Trans> - </ButtonText> - </Button> - </View> - </Dialog.Inner> + <View + style={[ + a.mx_auto, + a.border, + t.atoms.border_contrast_low, + a.rounded_xs, + a.overflow_hidden, + a.align_center, + ]}> + <ReactCrop + crop={crop} + aspect={aspectRatio} + circularCrop={circularCrop} + onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)} + className="ReactCrop--no-animate" + onDragStart={() => setIsDragging(true)} + onDragEnd={() => setIsDragging(false)}> + <img src={source.path} style={{maxHeight: `50vh`}} /> + </ReactCrop> + {/* Eat clicks when dragging, otherwise mousing up over the backdrop + causes the dialog to close */} + {isDragging && <View style={[a.fixed, a.inset_0]} />} + </View> ) } diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx index a7eae15dd..3687dce90 100644 --- a/src/view/com/modals/CreateOrEditList.tsx +++ b/src/view/com/modals/CreateOrEditList.tsx @@ -15,24 +15,23 @@ import {useLingui} from '@lingui/react' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {compressIfNeeded} from '#/lib/media/manip' -import {type PickerImage} from '#/lib/media/picker.shared' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {enforceLen} from '#/lib/strings/helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {colors, gradients, s} from '#/lib/styles' import {useTheme} from '#/lib/ThemeContext' +import {type ImageMeta} from '#/state/gallery' import {useModalControls} from '#/state/modals' import { useListCreateMutation, useListMetadataMutation, } from '#/state/queries/list' import {useAgent} from '#/state/session' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {Text} from '../util/text/Text' -import * as Toast from '../util/Toast' -import {EditableUserAvatar} from '../util/UserAvatar' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import {Text} from '#/view/com/util/text/Text' +import * as Toast from '#/view/com/util/Toast' +import {EditableUserAvatar} from '#/view/com/util/UserAvatar' const MAX_NAME = 64 // todo const MAX_DESCRIPTION = 300 // todo @@ -95,7 +94,7 @@ export function Component({ const isDescriptionOver = graphemeLength > MAX_DESCRIPTION const [avatar, setAvatar] = useState<string | undefined>(list?.avatar) - const [newAvatar, setNewAvatar] = useState<PickerImage | undefined | null>() + const [newAvatar, setNewAvatar] = useState<ImageMeta | undefined | null>() const onDescriptionChange = useCallback( (newText: string) => { @@ -112,16 +111,15 @@ export function Component({ }, [closeModal]) const onSelectNewAvatar = useCallback( - async (img: PickerImage | null) => { + (img: ImageMeta | null) => { if (!img) { setNewAvatar(null) setAvatar(undefined) return } try { - const finalImg = await compressIfNeeded(img, 1000000) - setNewAvatar(finalImg) - setAvatar(finalImg.path) + setNewAvatar(img) + setAvatar(img.path) } catch (e: any) { setError(cleanError(e)) } diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 8fd927f16..1ec4052d0 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -10,7 +10,6 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' import * as DeleteAccountModal from './DeleteAccount' -import * as EditProfileModal from './EditProfile' import * as InviteCodesModal from './InviteCodes' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' @@ -48,10 +47,7 @@ export function ModalsContainer() { let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let element - if (activeModal?.name === 'edit-profile') { - snapPoints = EditProfileModal.snapPoints - element = <EditProfileModal.Component {...activeModal} /> - } else if (activeModal?.name === 'create-or-edit-list') { + if (activeModal?.name === 'create-or-edit-list') { snapPoints = CreateOrEditListModal.snapPoints element = <CreateOrEditListModal.Component {...activeModal} /> } else if (activeModal?.name === 'user-add-remove-lists') { diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 524780099..3374c3132 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -8,9 +8,7 @@ import {type Modal as ModalIface} from '#/state/modals' import {useModalControls, useModals} from '#/state/modals' import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' -import * as CropImageModal from './CropImage.web' import * as DeleteAccountModal from './DeleteAccount' -import * as EditProfileModal from './EditProfile' import * as InviteCodesModal from './InviteCodes' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' @@ -45,9 +43,6 @@ function Modal({modal}: {modal: ModalIface}) { } const onPressMask = () => { - if (modal.name === 'crop-image') { - return // dont close on mask presses during crop - } closeModal() } const onInnerPress = () => { @@ -56,14 +51,10 @@ function Modal({modal}: {modal: ModalIface}) { } let element - if (modal.name === 'edit-profile') { - element = <EditProfileModal.Component {...modal} /> - } else if (modal.name === 'create-or-edit-list') { + if (modal.name === 'create-or-edit-list') { element = <CreateOrEditListModal.Component {...modal} /> } else if (modal.name === 'user-add-remove-lists') { element = <UserAddRemoveLists.Component {...modal} /> - } else if (modal.name === 'crop-image') { - element = <CropImageModal.Component {...modal} /> } else if (modal.name === 'delete-account') { element = <DeleteAccountModal.Component /> } else if (modal.name === 'invite-codes') { diff --git a/src/view/com/util/EventStopper.tsx b/src/view/com/util/EventStopper.tsx index 8f5f5cf54..24bc47afd 100644 --- a/src/view/com/util/EventStopper.tsx +++ b/src/view/com/util/EventStopper.tsx @@ -1,5 +1,5 @@ -import React from 'react' -import {View, ViewStyle} from 'react-native' +import {View, type ViewStyle} from 'react-native' +import type React from 'react' /** * This utility function captures events and stops diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 2450c111b..326a2fff8 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -1,4 +1,4 @@ -import React, {memo, useMemo} from 'react' +import React, {memo, useCallback, useMemo, useState} from 'react' import { Image, Pressable, @@ -14,36 +14,38 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {usePalette} from '#/lib/hooks/usePalette' import { useCameraPermission, usePhotoLibraryPermission, } from '#/lib/hooks/usePermissions' +import {compressIfNeeded} from '#/lib/media/manip' +import {openCamera, openCropper, openPicker} from '#/lib/media/picker' +import {type PickerImage} from '#/lib/media/picker.shared' import {makeProfileLink} from '#/lib/routes/links' -import {colors} from '#/lib/styles' import {logger} from '#/logger' import {isAndroid, isNative, isWeb} from '#/platform/detection' -import {precacheProfile} from '#/state/queries/profile' +import { + type ComposerImage, + compressImage, + createComposerImage, +} from '#/state/gallery' +import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' +import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' import {HighPriorityImage} from '#/view/com/util/images/Image' -import {tokens, useTheme} from '#/alf' +import {atoms as a, tokens, useTheme} from '#/alf' +import {useDialogControl} from '#/components/Dialog' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { - Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, - Camera_Stroke2_Corner0_Rounded as Camera, + Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon, + Camera_Stroke2_Corner0_Rounded as CameraIcon, } from '#/components/icons/Camera' -import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Link} from '#/components/Link' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import * as Menu from '#/components/Menu' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import type * as bsky from '#/types/bsky' -import { - openCamera, - openCropper, - openPicker, - type RNImage, -} from '../../../lib/media/picker' export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' @@ -63,7 +65,7 @@ interface UserAvatarProps extends BaseUserAvatarProps { } interface EditableUserAvatarProps extends BaseUserAvatarProps { - onSelectNewAvatar: (img: RNImage | null) => void + onSelectNewAvatar: (img: PickerImage | null) => void } interface PreviewableUserAvatarProps extends BaseUserAvatarProps { @@ -195,8 +197,8 @@ let UserAvatar = ({ onLoad, style, }: UserAvatarProps): React.ReactNode => { - const pal = usePalette('default') - const backgroundColor = pal.colors.backgroundLight + const t = useTheme() + const backgroundColor = t.palette.contrast_25 const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') const aviStyle = useMemo(() => { @@ -221,15 +223,22 @@ let UserAvatar = ({ return null } return ( - <View style={[styles.alertIconContainer, pal.view]}> + <View + style={[ + a.absolute, + a.right_0, + a.bottom_0, + a.rounded_full, + {backgroundColor: t.palette.white}, + ]}> <FontAwesomeIcon icon="exclamation-circle" - style={styles.alertIcon} + style={{color: t.palette.negative_400}} size={Math.floor(size / 3)} /> </View> ) - }, [moderation?.alert, size, pal]) + }, [moderation?.alert, size, t]) const containerStyle = useMemo(() => { return [ @@ -288,14 +297,18 @@ let EditableUserAvatar = ({ onSelectNewAvatar, }: EditableUserAvatarProps): React.ReactNode => { const t = useTheme() - const pal = usePalette('default') const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + const [rawImage, setRawImage] = useState<ComposerImage | undefined>() + const editImageDialogControl = useDialogControl() + const sheetWrapper = useSheetWrapper() + const circular = type !== 'algo' && type !== 'list' + const aviStyle = useMemo(() => { - if (type === 'algo' || type === 'list') { + if (!circular) { return { width: size, height: size, @@ -307,7 +320,7 @@ let EditableUserAvatar = ({ height: size, borderRadius: Math.floor(size / 2), } - }, [type, size]) + }, [circular, size]) const onOpenCamera = React.useCallback(async () => { if (!(await requestCameraAccessIfNeeded())) { @@ -315,9 +328,11 @@ let EditableUserAvatar = ({ } onSelectNewAvatar( - await openCamera({ - aspect: [1, 1], - }), + await compressIfNeeded( + await openCamera({ + aspect: [1, 1], + }), + ), ) }, [onSelectNewAvatar, requestCameraAccessIfNeeded]) @@ -337,91 +352,129 @@ let EditableUserAvatar = ({ } try { - const croppedImage = await openCropper({ - imageUri: item.path, - shape: 'circle', - aspectRatio: 1, - }) - onSelectNewAvatar(croppedImage) + if (isNative) { + onSelectNewAvatar( + await compressIfNeeded( + await openCropper({ + imageUri: item.path, + shape: circular ? 'circle' : 'rectangle', + aspectRatio: 1, + }), + ), + ) + } else { + setRawImage(await createComposerImage(item)) + editImageDialogControl.open() + } } catch (e: any) { // Don't log errors for cancelling selection to sentry on ios or android if (!String(e).toLowerCase().includes('cancel')) { logger.error('Failed to crop banner', {error: e}) } } - }, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper]) + }, [ + onSelectNewAvatar, + requestPhotoAccessIfNeeded, + sheetWrapper, + editImageDialogControl, + circular, + ]) const onRemoveAvatar = React.useCallback(() => { onSelectNewAvatar(null) }, [onSelectNewAvatar]) + const onChangeEditImage = useCallback( + async (image: ComposerImage) => { + const compressed = await compressImage(image) + onSelectNewAvatar(compressed) + }, + [onSelectNewAvatar], + ) + return ( - <Menu.Root> - <Menu.Trigger label={_(msg`Edit avatar`)}> - {({props}) => ( - <Pressable {...props} testID="changeAvatarBtn"> - {avatar ? ( - <HighPriorityImage - testID="userAvatarImage" - style={aviStyle} - source={{uri: avatar}} - accessibilityRole="image" - /> - ) : ( - <DefaultAvatar type={type} size={size} /> + <> + <Menu.Root> + <Menu.Trigger label={_(msg`Edit avatar`)}> + {({props}) => ( + <Pressable {...props} testID="changeAvatarBtn"> + {avatar ? ( + <HighPriorityImage + testID="userAvatarImage" + style={aviStyle} + source={{uri: avatar}} + accessibilityRole="image" + /> + ) : ( + <DefaultAvatar type={type} size={size} /> + )} + <View + style={[ + styles.editButtonContainer, + t.atoms.bg_contrast_25, + a.border, + t.atoms.border_contrast_low, + ]}> + <CameraFilledIcon height={14} width={14} style={t.atoms.text} /> + </View> + </Pressable> + )} + </Menu.Trigger> + <Menu.Outer showCancel> + <Menu.Group> + {isNative && ( + <Menu.Item + testID="changeAvatarCameraBtn" + label={_(msg`Upload from Camera`)} + onPress={onOpenCamera}> + <Menu.ItemText> + <Trans>Upload from Camera</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={CameraIcon} /> + </Menu.Item> )} - <View style={[styles.editButtonContainer, pal.btn]}> - <CameraFilled height={14} width={14} style={t.atoms.text} /> - </View> - </Pressable> - )} - </Menu.Trigger> - <Menu.Outer showCancel> - <Menu.Group> - {isNative && ( + <Menu.Item - testID="changeAvatarCameraBtn" - label={_(msg`Upload from Camera`)} - onPress={onOpenCamera}> + testID="changeAvatarLibraryBtn" + label={_(msg`Upload from Library`)} + onPress={onOpenLibrary}> <Menu.ItemText> - <Trans>Upload from Camera</Trans> + {isNative ? ( + <Trans>Upload from Library</Trans> + ) : ( + <Trans>Upload from Files</Trans> + )} </Menu.ItemText> - <Menu.ItemIcon icon={Camera} /> + <Menu.ItemIcon icon={LibraryIcon} /> </Menu.Item> + </Menu.Group> + {!!avatar && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="changeAvatarRemoveBtn" + label={_(msg`Remove Avatar`)} + onPress={onRemoveAvatar}> + <Menu.ItemText> + <Trans>Remove Avatar</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={TrashIcon} /> + </Menu.Item> + </Menu.Group> + </> )} + </Menu.Outer> + </Menu.Root> - <Menu.Item - testID="changeAvatarLibraryBtn" - label={_(msg`Upload from Library`)} - onPress={onOpenLibrary}> - <Menu.ItemText> - {isNative ? ( - <Trans>Upload from Library</Trans> - ) : ( - <Trans>Upload from Files</Trans> - )} - </Menu.ItemText> - <Menu.ItemIcon icon={Library} /> - </Menu.Item> - </Menu.Group> - {!!avatar && ( - <> - <Menu.Divider /> - <Menu.Group> - <Menu.Item - testID="changeAvatarRemoveBtn" - label={_(msg`Remove Avatar`)} - onPress={onRemoveAvatar}> - <Menu.ItemText> - <Trans>Remove Avatar</Trans> - </Menu.ItemText> - <Menu.ItemIcon icon={Trash} /> - </Menu.Item> - </Menu.Group> - </> - )} - </Menu.Outer> - </Menu.Root> + <EditImageDialog + control={editImageDialogControl} + image={rawImage} + onChange={onChangeEditImage} + aspectRatio={1} + circularCrop={circular} + /> + </> ) } EditableUserAvatar = memo(EditableUserAvatar) @@ -440,7 +493,7 @@ let PreviewableUserAvatar = ({ const onPress = React.useCallback(() => { onBeforePress?.() - precacheProfile(queryClient, profile) + unstableCacheProfileView(queryClient, profile) }, [profile, queryClient, onBeforePress]) const avatarEl = ( @@ -494,15 +547,5 @@ const styles = StyleSheet.create({ borderRadius: 12, alignItems: 'center', justifyContent: 'center', - backgroundColor: colors.gray5, - }, - alertIconContainer: { - position: 'absolute', - right: 0, - bottom: 0, - borderRadius: 100, - }, - alertIcon: { - color: colors.red3, }, }) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index ab7f25b80..3600f5c24 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,35 +1,36 @@ -import React from 'react' +import {useCallback, useState} from 'react' import {Pressable, StyleSheet, View} from 'react-native' import {Image} from 'expo-image' import {type ModerationUI} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {usePalette} from '#/lib/hooks/usePalette' import { useCameraPermission, usePhotoLibraryPermission, } from '#/lib/hooks/usePermissions' -import {colors} from '#/lib/styles' -import {useTheme} from '#/lib/ThemeContext' +import {compressIfNeeded} from '#/lib/media/manip' +import {openCamera, openCropper, openPicker} from '#/lib/media/picker' +import {type PickerImage} from '#/lib/media/picker.shared' import {logger} from '#/logger' import {isAndroid, isNative} from '#/platform/detection' +import { + type ComposerImage, + compressImage, + createComposerImage, +} from '#/state/gallery' +import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' import {EventStopper} from '#/view/com/util/EventStopper' -import {tokens, useTheme as useAlfTheme} from '#/alf' +import {atoms as a, tokens, useTheme} from '#/alf' +import {useDialogControl} from '#/components/Dialog' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { - Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, - Camera_Stroke2_Corner0_Rounded as Camera, + Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon, + Camera_Stroke2_Corner0_Rounded as CameraIcon, } from '#/components/icons/Camera' -import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import * as Menu from '#/components/Menu' -import { - openCamera, - openCropper, - openPicker, - type RNImage, -} from '../../../lib/media/picker' export function UserBanner({ type, @@ -40,28 +41,30 @@ export function UserBanner({ type?: 'labeler' | 'default' banner?: string | null moderation?: ModerationUI - onSelectNewBanner?: (img: RNImage | null) => void + onSelectNewBanner?: (img: PickerImage | null) => void }) { - const pal = usePalette('default') - const theme = useTheme() - const t = useAlfTheme() + const t = useTheme() const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const sheetWrapper = useSheetWrapper() + const [rawImage, setRawImage] = useState<ComposerImage | undefined>() + const editImageDialogControl = useDialogControl() - const onOpenCamera = React.useCallback(async () => { + const onOpenCamera = useCallback(async () => { if (!(await requestCameraAccessIfNeeded())) { return } onSelectNewBanner?.( - await openCamera({ - aspect: [3, 1], - }), + await compressIfNeeded( + await openCamera({ + aspect: [3, 1], + }), + ), ) }, [onSelectNewBanner, requestCameraAccessIfNeeded]) - const onOpenLibrary = React.useCallback(async () => { + const onOpenLibrary = useCallback(async () => { if (!(await requestPhotoAccessIfNeeded())) { return } @@ -71,105 +74,141 @@ export function UserBanner({ } try { - onSelectNewBanner?.( - await openCropper({ - imageUri: items[0].path, - aspectRatio: 3 / 1, - }), - ) + if (isNative) { + onSelectNewBanner?.( + await compressIfNeeded( + await openCropper({ + imageUri: items[0].path, + aspectRatio: 3 / 1, + }), + ), + ) + } else { + setRawImage(await createComposerImage(items[0])) + editImageDialogControl.open() + } } catch (e: any) { if (!String(e).includes('Canceled')) { logger.error('Failed to crop banner', {error: e}) } } - }, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper]) + }, [ + onSelectNewBanner, + requestPhotoAccessIfNeeded, + sheetWrapper, + editImageDialogControl, + ]) - const onRemoveBanner = React.useCallback(() => { + const onRemoveBanner = useCallback(() => { onSelectNewBanner?.(null) }, [onSelectNewBanner]) + const onChangeEditImage = useCallback( + async (image: ComposerImage) => { + const compressed = await compressImage(image) + onSelectNewBanner?.(compressed) + }, + [onSelectNewBanner], + ) + // setUserBanner is only passed as prop on the EditProfile component return onSelectNewBanner ? ( - <EventStopper onKeyDown={true}> - <Menu.Root> - <Menu.Trigger label={_(msg`Edit avatar`)}> - {({props}) => ( - <Pressable {...props} testID="changeBannerBtn"> - {banner ? ( - <Image - testID="userBannerImage" - style={styles.bannerImage} - source={{uri: banner}} - accessible={true} - accessibilityIgnoresInvertColors - /> - ) : ( + <> + <EventStopper onKeyDown={true}> + <Menu.Root> + <Menu.Trigger label={_(msg`Edit avatar`)}> + {({props}) => ( + <Pressable {...props} testID="changeBannerBtn"> + {banner ? ( + <Image + testID="userBannerImage" + style={styles.bannerImage} + source={{uri: banner}} + accessible={true} + accessibilityIgnoresInvertColors + /> + ) : ( + <View + testID="userBannerFallback" + style={[styles.bannerImage, t.atoms.bg_contrast_25]} + /> + )} <View - testID="userBannerFallback" - style={[styles.bannerImage, t.atoms.bg_contrast_25]} - /> + style={[ + styles.editButtonContainer, + t.atoms.bg_contrast_25, + a.border, + t.atoms.border_contrast_low, + ]}> + <CameraFilledIcon + height={14} + width={14} + style={t.atoms.text} + /> + </View> + </Pressable> + )} + </Menu.Trigger> + <Menu.Outer showCancel> + <Menu.Group> + {isNative && ( + <Menu.Item + testID="changeBannerCameraBtn" + label={_(msg`Upload from Camera`)} + onPress={onOpenCamera}> + <Menu.ItemText> + <Trans>Upload from Camera</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={CameraIcon} /> + </Menu.Item> )} - <View style={[styles.editButtonContainer, pal.btn]}> - <CameraFilled height={14} width={14} style={t.atoms.text} /> - </View> - </Pressable> - )} - </Menu.Trigger> - <Menu.Outer showCancel> - <Menu.Group> - {isNative && ( + <Menu.Item - testID="changeBannerCameraBtn" - label={_(msg`Upload from Camera`)} - onPress={onOpenCamera}> + testID="changeBannerLibraryBtn" + label={_(msg`Upload from Library`)} + onPress={onOpenLibrary}> <Menu.ItemText> - <Trans>Upload from Camera</Trans> + {isNative ? ( + <Trans>Upload from Library</Trans> + ) : ( + <Trans>Upload from Files</Trans> + )} </Menu.ItemText> - <Menu.ItemIcon icon={Camera} /> + <Menu.ItemIcon icon={LibraryIcon} /> </Menu.Item> + </Menu.Group> + {!!banner && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="changeBannerRemoveBtn" + label={_(msg`Remove Banner`)} + onPress={onRemoveBanner}> + <Menu.ItemText> + <Trans>Remove Banner</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={TrashIcon} /> + </Menu.Item> + </Menu.Group> + </> )} + </Menu.Outer> + </Menu.Root> + </EventStopper> - <Menu.Item - testID="changeBannerLibraryBtn" - label={_(msg`Upload from Library`)} - onPress={onOpenLibrary}> - <Menu.ItemText> - {isNative ? ( - <Trans>Upload from Library</Trans> - ) : ( - <Trans>Upload from Files</Trans> - )} - </Menu.ItemText> - <Menu.ItemIcon icon={Library} /> - </Menu.Item> - </Menu.Group> - {!!banner && ( - <> - <Menu.Divider /> - <Menu.Group> - <Menu.Item - testID="changeBannerRemoveBtn" - label={_(msg`Remove Banner`)} - onPress={onRemoveBanner}> - <Menu.ItemText> - <Trans>Remove Banner</Trans> - </Menu.ItemText> - <Menu.ItemIcon icon={Trash} /> - </Menu.Item> - </Menu.Group> - </> - )} - </Menu.Outer> - </Menu.Root> - </EventStopper> + <EditImageDialog + control={editImageDialogControl} + image={rawImage} + onChange={onChangeEditImage} + aspectRatio={3} + /> + </> ) : banner && !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( <Image testID="userBannerImage" - style={[ - styles.bannerImage, - {backgroundColor: theme.palette.default.backgroundLight}, - ]} + style={[styles.bannerImage, t.atoms.bg_contrast_25]} contentFit="cover" source={{uri: banner}} blurRadius={moderation?.blur ? 100 : 0} @@ -197,7 +236,6 @@ const styles = StyleSheet.create({ borderRadius: 12, alignItems: 'center', justifyContent: 'center', - backgroundColor: colors.gray5, }, bannerImage: { width: '100%', |