New Edit Profile dialog on web, use new Edit Image dialog everywhere (#8220)

This commit is contained in:
Samuel Newman
2025-05-06 22:50:28 +03:00
committed by GitHub
parent 3f7dc9a8e5
commit 4c8fd006f6
21 changed files with 532 additions and 441 deletions
@@ -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 => {
@@ -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 [crop, setCrop] = useState(initialCrop)
const isEmpty = !crop || (crop.width || crop.height) === 0
const isNew = initialCrop ? true : !isEmpty
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>
)
}
+9 -11
View File
@@ -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))
}
+1 -5
View File
@@ -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') {
+1 -10
View File
@@ -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') {
+2 -2
View File
@@ -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
+148 -105
View File
@@ -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])
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>
)}
const onChangeEditImage = useCallback(
async (image: ComposerImage) => {
const compressed = await compressImage(image)
onSelectNewAvatar(compressed)
},
[onSelectNewAvatar],
)
<Menu.Item
testID="changeAvatarLibraryBtn"
label={_(msg`Upload from Library`)}
onPress={onOpenLibrary}>
<Menu.ItemText>
{isNative ? (
<Trans>Upload from Library</Trans>
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"
/>
) : (
<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,
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="changeAvatarRemoveBtn"
label={_(msg`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={CameraIcon} />
</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={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>
<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,
},
})
+141 -103
View File
@@ -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
/>
) : (
<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,
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="changeBannerRemoveBtn"
label={_(msg`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={CameraIcon} />
</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={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>
<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%',