forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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})