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