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
+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,
},
})