mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useCallback, useState} from 'react'
2import {
3 ActivityIndicator,
4 KeyboardAvoidingView,
5 ScrollView,
6 StyleSheet,
7 TextInput,
8 TouchableOpacity,
9 View,
10} from 'react-native'
11import {Image as RNImage} from 'react-native-image-crop-picker'
12import Animated, {FadeOut} from 'react-native-reanimated'
13import {LinearGradient} from 'expo-linear-gradient'
14import {AppBskyActorDefs} from '@atproto/api'
15import {msg, Trans} from '@lingui/macro'
16import {useLingui} from '@lingui/react'
17
18import {logger} from '#/logger'
19import {useModalControls} from '#/state/modals'
20import {useProfileUpdateMutation} from '#/state/queries/profile'
21import {useAnalytics} from 'lib/analytics/analytics'
22import {MAX_DESCRIPTION, MAX_DISPLAY_NAME} from 'lib/constants'
23import {usePalette} from 'lib/hooks/usePalette'
24import {compressIfNeeded} from 'lib/media/manip'
25import {cleanError} from 'lib/strings/errors'
26import {enforceLen} from 'lib/strings/helpers'
27import {colors, gradients, s} from 'lib/styles'
28import {useTheme} from 'lib/ThemeContext'
29import {isWeb} from 'platform/detection'
30import {ErrorMessage} from '../util/error/ErrorMessage'
31import {Text} from '../util/text/Text'
32import * as Toast from '../util/Toast'
33import {EditableUserAvatar} from '../util/UserAvatar'
34import {UserBanner} from '../util/UserBanner'
35
36const AnimatedTouchableOpacity =
37 Animated.createAnimatedComponent(TouchableOpacity)
38
39export const snapPoints = ['fullscreen']
40
41export function Component({
42 profile,
43 onUpdate,
44}: {
45 profile: AppBskyActorDefs.ProfileViewDetailed
46 onUpdate?: () => void
47}) {
48 const pal = usePalette('default')
49 const theme = useTheme()
50 const {track} = useAnalytics()
51 const {_} = useLingui()
52 const {closeModal} = useModalControls()
53 const updateMutation = useProfileUpdateMutation()
54 const [imageError, setImageError] = useState<string>('')
55 const [displayName, setDisplayName] = useState<string>(
56 profile.displayName || '',
57 )
58 const [description, setDescription] = useState<string>(
59 profile.description || '',
60 )
61 const [userBanner, setUserBanner] = useState<string | undefined | null>(
62 profile.banner,
63 )
64 const [userAvatar, setUserAvatar] = useState<string | undefined | null>(
65 profile.avatar,
66 )
67 const [newUserBanner, setNewUserBanner] = useState<
68 RNImage | undefined | null
69 >()
70 const [newUserAvatar, setNewUserAvatar] = useState<
71 RNImage | undefined | null
72 >()
73 const onPressCancel = () => {
74 closeModal()
75 }
76 const onSelectNewAvatar = useCallback(
77 async (img: RNImage | null) => {
78 setImageError('')
79 if (img === null) {
80 setNewUserAvatar(null)
81 setUserAvatar(null)
82 return
83 }
84 track('EditProfile:AvatarSelected')
85 try {
86 const finalImg = await compressIfNeeded(img, 1000000)
87 setNewUserAvatar(finalImg)
88 setUserAvatar(finalImg.path)
89 } catch (e: any) {
90 setImageError(cleanError(e))
91 }
92 },
93 [track, setNewUserAvatar, setUserAvatar, setImageError],
94 )
95
96 const onSelectNewBanner = useCallback(
97 async (img: RNImage | null) => {
98 setImageError('')
99 if (!img) {
100 setNewUserBanner(null)
101 setUserBanner(null)
102 return
103 }
104 track('EditProfile:BannerSelected')
105 try {
106 const finalImg = await compressIfNeeded(img, 1000000)
107 setNewUserBanner(finalImg)
108 setUserBanner(finalImg.path)
109 } catch (e: any) {
110 setImageError(cleanError(e))
111 }
112 },
113 [track, setNewUserBanner, setUserBanner, setImageError],
114 )
115
116 const onPressSave = useCallback(async () => {
117 track('EditProfile:Save')
118 setImageError('')
119 try {
120 await updateMutation.mutateAsync({
121 profile,
122 updates: {
123 displayName,
124 description,
125 },
126 newUserAvatar,
127 newUserBanner,
128 })
129 Toast.show(_(msg`Profile updated`))
130 onUpdate?.()
131 closeModal()
132 } catch (e: any) {
133 logger.error('Failed to update user profile', {message: String(e)})
134 }
135 }, [
136 track,
137 updateMutation,
138 profile,
139 onUpdate,
140 closeModal,
141 displayName,
142 description,
143 newUserAvatar,
144 newUserBanner,
145 setImageError,
146 _,
147 ])
148
149 return (
150 <KeyboardAvoidingView style={s.flex1} behavior="height">
151 <ScrollView style={[pal.view]} testID="editProfileModal">
152 <Text style={[styles.title, pal.text]}>
153 <Trans>Edit my profile</Trans>
154 </Text>
155 <View style={styles.photos}>
156 <UserBanner
157 banner={userBanner}
158 onSelectNewBanner={onSelectNewBanner}
159 />
160 <View style={[styles.avi, {borderColor: pal.colors.background}]}>
161 <EditableUserAvatar
162 size={80}
163 avatar={userAvatar}
164 onSelectNewAvatar={onSelectNewAvatar}
165 />
166 </View>
167 </View>
168 {updateMutation.isError && (
169 <View style={styles.errorContainer}>
170 <ErrorMessage message={cleanError(updateMutation.error)} />
171 </View>
172 )}
173 {imageError !== '' && (
174 <View style={styles.errorContainer}>
175 <ErrorMessage message={imageError} />
176 </View>
177 )}
178 <View style={styles.form}>
179 <View>
180 <Text style={[styles.label, pal.text]}>
181 <Trans>Display Name</Trans>
182 </Text>
183 <TextInput
184 testID="editProfileDisplayNameInput"
185 style={[styles.textInput, pal.border, pal.text]}
186 placeholder={_(msg`e.g. Alice Roberts`)}
187 placeholderTextColor={colors.gray4}
188 value={displayName}
189 onChangeText={v =>
190 setDisplayName(enforceLen(v, MAX_DISPLAY_NAME))
191 }
192 accessible={true}
193 accessibilityLabel={_(msg`Display name`)}
194 accessibilityHint={_(msg`Edit your display name`)}
195 />
196 </View>
197 <View style={s.pb10}>
198 <Text style={[styles.label, pal.text]}>
199 <Trans>Description</Trans>
200 </Text>
201 <TextInput
202 testID="editProfileDescriptionInput"
203 style={[styles.textArea, pal.border, pal.text]}
204 placeholder={_(msg`e.g. Artist, dog-lover, and avid reader.`)}
205 placeholderTextColor={colors.gray4}
206 keyboardAppearance={theme.colorScheme}
207 multiline
208 value={description}
209 onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
210 accessible={true}
211 accessibilityLabel={_(msg`Description`)}
212 accessibilityHint={_(msg`Edit your profile description`)}
213 />
214 </View>
215 {updateMutation.isPending ? (
216 <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
217 <ActivityIndicator />
218 </View>
219 ) : (
220 <TouchableOpacity
221 testID="editProfileSaveBtn"
222 style={s.mt10}
223 onPress={onPressSave}
224 accessibilityRole="button"
225 accessibilityLabel={_(msg`Save`)}
226 accessibilityHint={_(msg`Saves any changes to your profile`)}>
227 <LinearGradient
228 colors={[gradients.blueLight.start, gradients.blueLight.end]}
229 start={{x: 0, y: 0}}
230 end={{x: 1, y: 1}}
231 style={[styles.btn]}>
232 <Text style={[s.white, s.bold]}>
233 <Trans>Save Changes</Trans>
234 </Text>
235 </LinearGradient>
236 </TouchableOpacity>
237 )}
238 {!updateMutation.isPending && (
239 <AnimatedTouchableOpacity
240 exiting={!isWeb ? FadeOut : undefined}
241 testID="editProfileCancelBtn"
242 style={s.mt5}
243 onPress={onPressCancel}
244 accessibilityRole="button"
245 accessibilityLabel={_(msg`Cancel profile editing`)}
246 accessibilityHint=""
247 onAccessibilityEscape={onPressCancel}>
248 <View style={[styles.btn]}>
249 <Text style={[s.black, s.bold, pal.text]}>
250 <Trans>Cancel</Trans>
251 </Text>
252 </View>
253 </AnimatedTouchableOpacity>
254 )}
255 </View>
256 </ScrollView>
257 </KeyboardAvoidingView>
258 )
259}
260
261const styles = StyleSheet.create({
262 title: {
263 textAlign: 'center',
264 fontWeight: 'bold',
265 fontSize: 24,
266 marginBottom: 18,
267 },
268 label: {
269 fontWeight: 'bold',
270 paddingHorizontal: 4,
271 paddingBottom: 4,
272 marginTop: 20,
273 },
274 form: {
275 paddingHorizontal: 14,
276 },
277 textInput: {
278 borderWidth: 1,
279 borderRadius: 6,
280 paddingHorizontal: 14,
281 paddingVertical: 10,
282 fontSize: 16,
283 },
284 textArea: {
285 borderWidth: 1,
286 borderRadius: 6,
287 paddingHorizontal: 12,
288 paddingTop: 10,
289 fontSize: 16,
290 height: 120,
291 textAlignVertical: 'top',
292 },
293 btn: {
294 flexDirection: 'row',
295 alignItems: 'center',
296 justifyContent: 'center',
297 width: '100%',
298 borderRadius: 32,
299 padding: 10,
300 marginBottom: 10,
301 },
302 avi: {
303 position: 'absolute',
304 top: 80,
305 left: 24,
306 width: 84,
307 height: 84,
308 borderWidth: 2,
309 borderRadius: 42,
310 },
311 photos: {
312 marginBottom: 36,
313 marginHorizontal: -14,
314 },
315 errorContainer: {marginTop: 20},
316})