Convert profile edit avatar/banner dropdown menus to new menu (#3177)

* convert profile edit dropdown menu to new menu

fix banner text

add `showCancel` prop to menu outer

banner dropdown to menu

add Cancel button to menu

replace user avatar dropdown with menu

add StreamingLive icon

add camera icon

* remove export

* use new camera icon

* adjust icon color
zio/stable
Hailey 2024-03-12 10:17:27 -07:00 committed by GitHub
parent 80cc1f18a2
commit 8123299192
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 284 additions and 225 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM12 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM13.965 5h-3.93L8.63 7.11A2 2 0 0 1 6.965 8H4v11h16V8h-2.965a2 2 0 0 1-1.664-.89L13.965 5ZM12 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 447 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4Zm8 12.5c1.253 0 2.197.609 2.674 1.5H9.326c.477-.891 1.42-1.5 2.674-1.5Zm0-2c2.404 0 4.235 1.475 4.822 3.5H20V6H4v12h3.178c.587-2.025 2.418-3.5 4.822-3.5Zm-1.25-3.75a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0ZM12 7.5a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5Zm5.75 2a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 527 B

View File

@ -16,6 +16,10 @@ import {
ItemTextProps, ItemTextProps,
ItemIconProps, ItemIconProps,
} from '#/components/Menu/types' } from '#/components/Menu/types'
import {Button, ButtonText} from '#/components/Button'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from 'platform/detection'
export {useDialogControl as useMenuControl} from '#/components/Dialog' export {useDialogControl as useMenuControl} from '#/components/Dialog'
@ -68,7 +72,10 @@ export function Trigger({children, label}: TriggerProps) {
}) })
} }
export function Outer({children}: React.PropsWithChildren<{}>) { export function Outer({
children,
showCancel,
}: React.PropsWithChildren<{showCancel?: boolean}>) {
const context = React.useContext(Context) const context = React.useContext(Context)
return ( return (
@ -78,7 +85,10 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
{/* Re-wrap with context since Dialogs are portal-ed to root */} {/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}> <Context.Provider value={context}>
<Dialog.ScrollableInner label="Menu TODO"> <Dialog.ScrollableInner label="Menu TODO">
<View style={[a.gap_lg]}>{children}</View> <View style={[a.gap_lg]}>
{children}
{isNative && showCancel && <Cancel />}
</View>
<View style={{height: a.gap_lg.gap}} /> <View style={{height: a.gap_lg.gap}} />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
</Context.Provider> </Context.Provider>
@ -185,6 +195,22 @@ export function Group({children, style}: GroupProps) {
) )
} }
function Cancel() {
const {_} = useLingui()
const {control} = React.useContext(Context)
return (
<Button
label={_(msg`Close this dialog`)}
size="small"
variant="ghost"
color="secondary"
onPress={() => control.close()}>
<ButtonText>Cancel</ButtonText>
</Button>
)
}
export function Divider() { export function Divider() {
return null return null
} }

View File

@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Camera_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM13.965 5h-3.93L8.63 7.11A2 2 0 0 1 6.965 8H4v11h16V8h-2.965a2 2 0 0 1-1.664-.89L13.965 5ZM12 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
})
export const Camera_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM12 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
})

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const StreamingLive_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4Zm8 12.5c1.253 0 2.197.609 2.674 1.5H9.326c.477-.891 1.42-1.5 2.674-1.5Zm0-2c2.404 0 4.235 1.475 4.822 3.5H20V6H4v12h3.178c.587-2.025 2.418-3.5 4.822-3.5Zm-1.25-3.75a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0ZM12 7.5a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5Zm5.75 2a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z',
})

View File

@ -1,9 +1,13 @@
import React, {memo, useMemo} from 'react' import React, {memo, useMemo} from 'react'
import {Image, StyleSheet, View} from 'react-native' import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
import Svg, {Circle, Rect, Path} from 'react-native-svg' import Svg, {Circle, Rect, Path} from 'react-native-svg'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {ModerationUI} from '@atproto/api' import {ModerationUI} from '@atproto/api'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import { import {
usePhotoLibraryPermission, usePhotoLibraryPermission,
@ -11,12 +15,16 @@ import {
} from 'lib/hooks/usePermissions' } from 'lib/hooks/usePermissions'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb, isAndroid} from 'platform/detection' import {isWeb, isAndroid, isNative} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {UserPreviewLink} from './UserPreviewLink' import {UserPreviewLink} from './UserPreviewLink'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' import * as Menu from '#/components/Menu'
import {useLingui} from '@lingui/react' import {
import {msg} from '@lingui/macro' Camera_Stroke2_Corner0_Rounded as Camera,
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
} 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 {useTheme} from '#/alf'
export type UserAvatarType = 'user' | 'algo' | 'list' export type UserAvatarType = 'user' | 'algo' | 'list'
@ -196,6 +204,7 @@ let EditableUserAvatar = ({
avatar, avatar,
onSelectNewAvatar, onSelectNewAvatar,
}: EditableUserAvatarProps): React.ReactNode => { }: EditableUserAvatarProps): React.ReactNode => {
const t = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestCameraAccessIfNeeded} = useCameraPermission()
@ -216,118 +225,115 @@ let EditableUserAvatar = ({
} }
}, [type, size]) }, [type, size])
const dropdownItems = useMemo( const onOpenCamera = React.useCallback(async () => {
() => if (!(await requestCameraAccessIfNeeded())) {
[ return
!isWeb && { }
testID: 'changeAvatarCameraBtn',
label: _(msg`Camera`),
icon: {
ios: {
name: 'camera',
},
android: 'ic_menu_camera',
web: 'camera',
},
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewAvatar( onSelectNewAvatar(
await openCamera({ await openCamera({
width: 1000, width: 1000,
height: 1000, height: 1000,
cropperCircleOverlay: true, cropperCircleOverlay: true,
}), }),
) )
}, }, [onSelectNewAvatar, requestCameraAccessIfNeeded])
},
{
testID: 'changeAvatarLibraryBtn',
label: _(msg`Library`),
icon: {
ios: {
name: 'photo.on.rectangle.angled',
},
android: 'ic_menu_gallery',
web: 'gallery',
},
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker({ const onOpenLibrary = React.useCallback(async () => {
aspect: [1, 1], if (!(await requestPhotoAccessIfNeeded())) {
}) return
const item = items[0] }
if (!item) {
return
}
const croppedImage = await openCropper({ const items = await openPicker({
mediaType: 'photo', aspect: [1, 1],
cropperCircleOverlay: true, })
height: item.height, const item = items[0]
width: item.width, if (!item) {
path: item.path, return
}) }
onSelectNewAvatar(croppedImage) const croppedImage = await openCropper({
}, mediaType: 'photo',
}, cropperCircleOverlay: true,
!!avatar && { height: item.height,
label: 'separator', width: item.width,
}, path: item.path,
!!avatar && { })
testID: 'changeAvatarRemoveBtn',
label: _(msg`Remove`), onSelectNewAvatar(croppedImage)
icon: { }, [onSelectNewAvatar, requestPhotoAccessIfNeeded])
ios: {
name: 'trash', const onRemoveAvatar = React.useCallback(() => {
}, onSelectNewAvatar(null)
android: 'ic_delete', }, [onSelectNewAvatar])
web: ['far', 'trash-can'],
},
onPress: async () => {
onSelectNewAvatar(null)
},
},
].filter(Boolean) as DropdownItem[],
[
avatar,
onSelectNewAvatar,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
return ( return (
<NativeDropdown <Menu.Root>
testID="changeAvatarBtn" <Menu.Trigger label={_(msg`Edit avatar`)}>
items={dropdownItems} {({props}) => (
accessibilityLabel={_(msg`Image options`)} <TouchableOpacity {...props} activeOpacity={0.8}>
accessibilityHint=""> {avatar ? (
{avatar ? ( <HighPriorityImage
<HighPriorityImage testID="userAvatarImage"
testID="userAvatarImage" style={aviStyle}
style={aviStyle} source={{uri: avatar}}
source={{uri: avatar}} accessibilityRole="image"
accessibilityRole="image" />
/> ) : (
) : ( <DefaultAvatar type={type} size={size} />
<DefaultAvatar type={type} size={size} /> )}
)} <View style={[styles.editButtonContainer, pal.btn]}>
<View style={[styles.editButtonContainer, pal.btn]}> <CameraFilled height={14} width={14} style={t.atoms.text} />
<FontAwesomeIcon </View>
icon="camera" </TouchableOpacity>
size={12} )}
color={pal.text.color as string} </Menu.Trigger>
/> <Menu.Outer showCancel>
</View> <Menu.Group>
</NativeDropdown> {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>
) : (
<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>
) )
} }
EditableUserAvatar = memo(EditableUserAvatar) EditableUserAvatar = memo(EditableUserAvatar)

View File

@ -1,21 +1,29 @@
import React, {useMemo} from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ModerationUI} from '@atproto/api' import {ModerationUI} from '@atproto/api'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {useTheme as useAlfTheme} from '#/alf'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import { import {
usePhotoLibraryPermission, usePhotoLibraryPermission,
useCameraPermission, useCameraPermission,
} from 'lib/hooks/usePermissions' } from 'lib/hooks/usePermissions'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb, isAndroid} from 'platform/detection' import {isAndroid, isNative} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {NativeDropdown, DropdownItem} from './forms/NativeDropdown' import {EventStopper} from 'view/com/util/EventStopper'
import * as Menu from '#/components/Menu'
import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
Camera_Stroke2_Corner0_Rounded as Camera,
} 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'
export function UserBanner({ export function UserBanner({
banner, banner,
@ -28,118 +36,120 @@ export function UserBanner({
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const theme = useTheme() const theme = useTheme()
const t = useAlfTheme()
const {_} = useLingui() const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const dropdownItems: DropdownItem[] = useMemo( const onOpenCamera = React.useCallback(async () => {
() => if (!(await requestCameraAccessIfNeeded())) {
[ return
!isWeb && { }
testID: 'changeBannerCameraBtn', onSelectNewBanner?.(
label: _(msg`Camera`), await openCamera({
icon: { width: 3000,
ios: { height: 1000,
name: 'camera', }),
}, )
android: 'ic_menu_camera', }, [onSelectNewBanner, requestCameraAccessIfNeeded])
web: 'camera',
},
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewBanner?.(
await openCamera({
width: 3000,
height: 1000,
}),
)
},
},
{
testID: 'changeBannerLibraryBtn',
label: _(msg`Library`),
icon: {
ios: {
name: 'photo.on.rectangle.angled',
},
android: 'ic_menu_gallery',
web: 'gallery',
},
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker()
if (!items[0]) {
return
}
onSelectNewBanner?.( const onOpenLibrary = React.useCallback(async () => {
await openCropper({ if (!(await requestPhotoAccessIfNeeded())) {
mediaType: 'photo', return
path: items[0].path, }
width: 3000, const items = await openPicker()
height: 1000, if (!items[0]) {
}), return
) }
},
}, onSelectNewBanner?.(
!!banner && { await openCropper({
testID: 'changeBannerRemoveBtn', mediaType: 'photo',
label: _(msg`Remove`), path: items[0].path,
icon: { width: 3000,
ios: { height: 1000,
name: 'trash', }),
}, )
android: 'ic_delete', }, [onSelectNewBanner, requestPhotoAccessIfNeeded])
web: ['far', 'trash-can'],
}, const onRemoveBanner = React.useCallback(() => {
onPress: () => { onSelectNewBanner?.(null)
onSelectNewBanner?.(null) }, [onSelectNewBanner])
},
},
].filter(Boolean) as DropdownItem[],
[
banner,
onSelectNewBanner,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
// setUserBanner is only passed as prop on the EditProfile component // setUserBanner is only passed as prop on the EditProfile component
return onSelectNewBanner ? ( return onSelectNewBanner ? (
<NativeDropdown <EventStopper onKeyDown={false}>
testID="changeBannerBtn" <Menu.Root>
items={dropdownItems} <Menu.Trigger label={_(msg`Edit avatar`)}>
accessibilityLabel={_(msg`Image options`)} {({props}) => (
accessibilityHint=""> <TouchableOpacity {...props} activeOpacity={0.8}>
{banner ? ( {banner ? (
<Image <Image
testID="userBannerImage" testID="userBannerImage"
style={styles.bannerImage} style={styles.bannerImage}
source={{uri: banner}} source={{uri: banner}}
accessible={true} accessible={true}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
/> />
) : ( ) : (
<View <View
testID="userBannerFallback" testID="userBannerFallback"
style={[styles.bannerImage, styles.defaultBanner]} style={[styles.bannerImage, styles.defaultBanner]}
/> />
)} )}
<View style={[styles.editButtonContainer, pal.btn]}> <View style={[styles.editButtonContainer, pal.btn]}>
<FontAwesomeIcon <CameraFilled height={14} width={14} style={t.atoms.text} />
icon="camera" </View>
size={12} </TouchableOpacity>
style={{color: colors.white}} )}
color={pal.text.color as string} </Menu.Trigger>
/> <Menu.Outer showCancel>
</View> <Menu.Group>
</NativeDropdown> {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>
) : (
<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>
) : banner && ) : banner &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<Image <Image