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