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