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