Bluesky app fork with some witchin' additions 馃挮
at main 260 lines 8.0 kB view raw
1import {useCallback, useState} from 'react' 2import {Pressable, StyleSheet, View} from 'react-native' 3import {Image} from 'expo-image' 4import {type ModerationUI} from '@atproto/api' 5import {msg} from '@lingui/core/macro' 6import {useLingui} from '@lingui/react' 7import {Trans} from '@lingui/react/macro' 8 9import { 10 useCameraPermission, 11 usePhotoLibraryPermission, 12} from '#/lib/hooks/usePermissions' 13import {compressIfNeeded} from '#/lib/media/manip' 14import {openCamera, openCropper, openPicker} from '#/lib/media/picker' 15import {type PickerImage} from '#/lib/media/picker.shared' 16import {isCancelledError} from '#/lib/strings/errors' 17import {logger} from '#/logger' 18import { 19 type ComposerImage, 20 compressImage, 21 createComposerImage, 22} from '#/state/gallery' 23import { 24 maybeModifyHighQualityImage, 25 useHighQualityImages, 26} from '#/state/preferences/high-quality-images' 27import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' 28import {EventStopper} from '#/view/com/util/EventStopper' 29import {atoms as a, tokens, useTheme} from '#/alf' 30import {useDialogControl} from '#/components/Dialog' 31import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 32import { 33 Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon, 34 Camera_Stroke2_Corner0_Rounded as CameraIcon, 35} from '#/components/icons/Camera' 36import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' 37import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 38import * as Menu from '#/components/Menu' 39import {IS_ANDROID, IS_NATIVE} from '#/env' 40 41export function UserBanner({ 42 type, 43 banner, 44 moderation, 45 onSelectNewBanner, 46}: { 47 type?: 'labeler' | 'default' 48 banner?: string | null 49 moderation?: ModerationUI 50 onSelectNewBanner?: (img: PickerImage | null) => void 51}) { 52 const t = useTheme() 53 const {_} = useLingui() 54 const {requestCameraAccessIfNeeded} = useCameraPermission() 55 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 56 const sheetWrapper = useSheetWrapper() 57 const [rawImage, setRawImage] = useState<ComposerImage | undefined>() 58 const editImageDialogControl = useDialogControl() 59 const highQualityImages = useHighQualityImages() 60 61 const onOpenCamera = useCallback(async () => { 62 if (!(await requestCameraAccessIfNeeded())) { 63 return 64 } 65 onSelectNewBanner?.( 66 await compressIfNeeded( 67 await openCamera({ 68 aspect: [3, 1], 69 }), 70 ), 71 ) 72 }, [onSelectNewBanner, requestCameraAccessIfNeeded]) 73 74 const onOpenLibrary = useCallback(async () => { 75 if (!(await requestPhotoAccessIfNeeded())) { 76 return 77 } 78 const items = await sheetWrapper(openPicker()) 79 if (!items[0]) { 80 return 81 } 82 83 try { 84 if (IS_NATIVE) { 85 onSelectNewBanner?.( 86 await compressIfNeeded( 87 await openCropper({ 88 imageUri: items[0].path, 89 aspectRatio: 3 / 1, 90 }), 91 ), 92 ) 93 } else { 94 setRawImage(await createComposerImage(items[0])) 95 editImageDialogControl.open() 96 } 97 } catch (e) { 98 // Don't log errors for cancelling selection to sentry on ios or android 99 if (!isCancelledError(e)) { 100 logger.error('Failed to crop banner', {error: e}) 101 } 102 } 103 }, [ 104 onSelectNewBanner, 105 requestPhotoAccessIfNeeded, 106 sheetWrapper, 107 editImageDialogControl, 108 ]) 109 110 const onRemoveBanner = useCallback(() => { 111 onSelectNewBanner?.(null) 112 }, [onSelectNewBanner]) 113 114 const onChangeEditImage = useCallback( 115 async (image: ComposerImage) => { 116 const compressed = await compressImage(image) 117 onSelectNewBanner?.(compressed) 118 }, 119 [onSelectNewBanner], 120 ) 121 122 // setUserBanner is only passed as prop on the EditProfile component 123 return onSelectNewBanner ? ( 124 <> 125 <EventStopper onKeyDown={true}> 126 <Menu.Root> 127 <Menu.Trigger label={_(msg`Edit avatar`)}> 128 {({props}) => ( 129 <Pressable {...props} testID="changeBannerBtn"> 130 {banner ? ( 131 <Image 132 testID="userBannerImage" 133 style={styles.bannerImage} 134 source={{ 135 uri: maybeModifyHighQualityImage( 136 banner, 137 highQualityImages, 138 ), 139 }} 140 accessible={true} 141 accessibilityIgnoresInvertColors 142 /> 143 ) : ( 144 <View 145 testID="userBannerFallback" 146 style={[styles.bannerImage, t.atoms.bg_contrast_25]} 147 /> 148 )} 149 <View 150 style={[ 151 styles.editButtonContainer, 152 t.atoms.bg_contrast_25, 153 a.border, 154 t.atoms.border_contrast_low, 155 ]}> 156 <CameraFilledIcon 157 height={14} 158 width={14} 159 style={t.atoms.text} 160 /> 161 </View> 162 </Pressable> 163 )} 164 </Menu.Trigger> 165 <Menu.Outer showCancel> 166 <Menu.Group> 167 {IS_NATIVE && ( 168 <Menu.Item 169 testID="changeBannerCameraBtn" 170 label={_(msg`Upload from Camera`)} 171 onPress={onOpenCamera}> 172 <Menu.ItemText> 173 <Trans>Upload from Camera</Trans> 174 </Menu.ItemText> 175 <Menu.ItemIcon icon={CameraIcon} /> 176 </Menu.Item> 177 )} 178 179 <Menu.Item 180 testID="changeBannerLibraryBtn" 181 label={_(msg`Upload from Library`)} 182 onPress={onOpenLibrary}> 183 <Menu.ItemText> 184 {IS_NATIVE ? ( 185 <Trans>Upload from Library</Trans> 186 ) : ( 187 <Trans>Upload from Files</Trans> 188 )} 189 </Menu.ItemText> 190 <Menu.ItemIcon icon={LibraryIcon} /> 191 </Menu.Item> 192 </Menu.Group> 193 {!!banner && ( 194 <> 195 <Menu.Divider /> 196 <Menu.Group> 197 <Menu.Item 198 testID="changeBannerRemoveBtn" 199 label={_(msg`Remove Banner`)} 200 onPress={onRemoveBanner}> 201 <Menu.ItemText> 202 <Trans>Remove Banner</Trans> 203 </Menu.ItemText> 204 <Menu.ItemIcon icon={TrashIcon} /> 205 </Menu.Item> 206 </Menu.Group> 207 </> 208 )} 209 </Menu.Outer> 210 </Menu.Root> 211 </EventStopper> 212 213 <EditImageDialog 214 control={editImageDialogControl} 215 image={rawImage} 216 onChange={onChangeEditImage} 217 aspectRatio={3} 218 /> 219 </> 220 ) : banner && 221 !((moderation?.blur && IS_ANDROID) /* android crashes with blur */) ? ( 222 <Image 223 testID="userBannerImage" 224 style={[styles.bannerImage, t.atoms.bg_contrast_25]} 225 contentFit="cover" 226 source={{uri: maybeModifyHighQualityImage(banner, highQualityImages)}} 227 blurRadius={moderation?.blur ? 100 : 0} 228 accessible={true} 229 accessibilityIgnoresInvertColors 230 /> 231 ) : ( 232 <View 233 testID="userBannerFallback" 234 style={[ 235 styles.bannerImage, 236 type === 'labeler' ? styles.labelerBanner : t.atoms.bg_contrast_25, 237 ]} 238 /> 239 ) 240} 241 242const styles = StyleSheet.create({ 243 editButtonContainer: { 244 position: 'absolute', 245 width: 24, 246 height: 24, 247 bottom: 8, 248 right: 24, 249 borderRadius: 12, 250 alignItems: 'center', 251 justifyContent: 'center', 252 }, 253 bannerImage: { 254 width: '100%', 255 height: 150, 256 }, 257 labelerBanner: { 258 backgroundColor: tokens.color.temp_purple, 259 }, 260})