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} 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})