diff options
Diffstat (limited to 'src/view/com/util')
-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 |
3 files changed, 288 insertions, 207 deletions
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%', |