mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {useCallback, useEffect, useState} from 'react'
2import {useWindowDimensions, View} from 'react-native'
3import {type AppBskyActorDefs} from '@atproto/api'
4import {msg, Plural, Trans} from '@lingui/macro'
5import {useLingui} from '@lingui/react'
6
7import {urls} from '#/lib/constants'
8import {cleanError} from '#/lib/strings/errors'
9import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
10import {logger} from '#/logger'
11import {type ImageMeta} from '#/state/gallery'
12import {useProfileUpdateMutation} from '#/state/queries/profile'
13import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
14import * as Toast from '#/view/com/util/Toast'
15import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
16import {UserBanner} from '#/view/com/util/UserBanner'
17import {atoms as a, useTheme} from '#/alf'
18import {Admonition} from '#/components/Admonition'
19import {Button, ButtonIcon, ButtonText} from '#/components/Button'
20import * as Dialog from '#/components/Dialog'
21import * as TextField from '#/components/forms/TextField'
22import {InlineLinkText} from '#/components/Link'
23import {Loader} from '#/components/Loader'
24import * as Prompt from '#/components/Prompt'
25import {Text} from '#/components/Typography'
26import {useSimpleVerificationState} from '#/components/verification'
27
28const DISPLAY_NAME_MAX_GRAPHEMES = 64
29const DESCRIPTION_MAX_GRAPHEMES = 256
30
31export function EditProfileDialog({
32 profile,
33 control,
34 onUpdate,
35}: {
36 profile: AppBskyActorDefs.ProfileViewDetailed
37 control: Dialog.DialogControlProps
38 onUpdate?: () => void
39}) {
40 const {_} = useLingui()
41 const cancelControl = Dialog.useDialogControl()
42 const [dirty, setDirty] = useState(false)
43 const {height} = useWindowDimensions()
44
45 const onPressCancel = useCallback(() => {
46 if (dirty) {
47 cancelControl.open()
48 } else {
49 control.close()
50 }
51 }, [dirty, control, cancelControl])
52
53 return (
54 <Dialog.Outer
55 control={control}
56 nativeOptions={{
57 preventDismiss: dirty,
58 minHeight: height,
59 }}
60 webOptions={{
61 onBackgroundPress: () => {
62 if (dirty) {
63 cancelControl.open()
64 } else {
65 control.close()
66 }
67 },
68 }}
69 testID="editProfileModal">
70 <DialogInner
71 profile={profile}
72 onUpdate={onUpdate}
73 setDirty={setDirty}
74 onPressCancel={onPressCancel}
75 />
76
77 <Prompt.Basic
78 control={cancelControl}
79 title={_(msg`Discard changes?`)}
80 description={_(msg`Are you sure you want to discard your changes?`)}
81 onConfirm={() => control.close()}
82 confirmButtonCta={_(msg`Discard`)}
83 confirmButtonColor="negative"
84 />
85 </Dialog.Outer>
86 )
87}
88
89function DialogInner({
90 profile,
91 onUpdate,
92 setDirty,
93 onPressCancel,
94}: {
95 profile: AppBskyActorDefs.ProfileViewDetailed
96 onUpdate?: () => void
97 setDirty: (dirty: boolean) => void
98 onPressCancel: () => void
99}) {
100 const {_} = useLingui()
101 const t = useTheme()
102 const control = Dialog.useDialogContext()
103 const verification = useSimpleVerificationState({
104 profile,
105 })
106 const {
107 mutateAsync: updateProfileMutation,
108 error: updateProfileError,
109 isError: isUpdateProfileError,
110 isPending: isUpdatingProfile,
111 } = useProfileUpdateMutation()
112 const [imageError, setImageError] = useState('')
113 const initialDisplayName = profile.displayName || ''
114 const [displayName, setDisplayName] = useState(initialDisplayName)
115 const initialDescription = profile.description || ''
116 const [description, setDescription] = useState(initialDescription)
117 const [userBanner, setUserBanner] = useState<string | undefined | null>(
118 profile.banner,
119 )
120 const [userAvatar, setUserAvatar] = useState<string | undefined | null>(
121 profile.avatar,
122 )
123 const [newUserBanner, setNewUserBanner] = useState<
124 ImageMeta | undefined | null
125 >()
126 const [newUserAvatar, setNewUserAvatar] = useState<
127 ImageMeta | undefined | null
128 >()
129
130 const dirty =
131 displayName !== initialDisplayName ||
132 description !== initialDescription ||
133 userAvatar !== profile.avatar ||
134 userBanner !== profile.banner
135
136 useEffect(() => {
137 setDirty(dirty)
138 }, [dirty, setDirty])
139
140 const onSelectNewAvatar = useCallback(
141 (img: ImageMeta | null) => {
142 setImageError('')
143 if (img === null) {
144 setNewUserAvatar(null)
145 setUserAvatar(null)
146 return
147 }
148 try {
149 setNewUserAvatar(img)
150 setUserAvatar(img.path)
151 } catch (e: any) {
152 setImageError(cleanError(e))
153 }
154 },
155 [setNewUserAvatar, setUserAvatar, setImageError],
156 )
157
158 const onSelectNewBanner = useCallback(
159 (img: ImageMeta | null) => {
160 setImageError('')
161 if (!img) {
162 setNewUserBanner(null)
163 setUserBanner(null)
164 return
165 }
166 try {
167 setNewUserBanner(img)
168 setUserBanner(img.path)
169 } catch (e: any) {
170 setImageError(cleanError(e))
171 }
172 },
173 [setNewUserBanner, setUserBanner, setImageError],
174 )
175
176 const onPressSave = useCallback(async () => {
177 setImageError('')
178 try {
179 await updateProfileMutation({
180 profile,
181 updates: {
182 displayName: displayName.trimEnd(),
183 description: description.trimEnd(),
184 },
185 newUserAvatar,
186 newUserBanner,
187 })
188 control.close(() => onUpdate?.())
189 Toast.show(_(msg({message: 'Profile updated', context: 'toast'})))
190 } catch (e: any) {
191 logger.error('Failed to update user profile', {message: String(e)})
192 }
193 }, [
194 updateProfileMutation,
195 profile,
196 onUpdate,
197 control,
198 displayName,
199 description,
200 newUserAvatar,
201 newUserBanner,
202 setImageError,
203 _,
204 ])
205
206 const displayNameTooLong = useWarnMaxGraphemeCount({
207 text: displayName,
208 maxCount: DISPLAY_NAME_MAX_GRAPHEMES,
209 })
210 const descriptionTooLong = useWarnMaxGraphemeCount({
211 text: description,
212 maxCount: DESCRIPTION_MAX_GRAPHEMES,
213 })
214
215 const cancelButton = useCallback(
216 () => (
217 <Button
218 label={_(msg`Cancel`)}
219 onPress={onPressCancel}
220 size="small"
221 color="primary"
222 variant="ghost"
223 style={[a.rounded_full]}
224 testID="editProfileCancelBtn">
225 <ButtonText style={[a.text_md]}>
226 <Trans>Cancel</Trans>
227 </ButtonText>
228 </Button>
229 ),
230 [onPressCancel, _],
231 )
232
233 const saveButton = useCallback(
234 () => (
235 <Button
236 label={_(msg`Save`)}
237 onPress={onPressSave}
238 disabled={
239 !dirty ||
240 isUpdatingProfile ||
241 displayNameTooLong ||
242 descriptionTooLong
243 }
244 size="small"
245 color="primary"
246 variant="ghost"
247 style={[a.rounded_full]}
248 testID="editProfileSaveBtn">
249 <ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}>
250 <Trans>Save</Trans>
251 </ButtonText>
252 {isUpdatingProfile && <ButtonIcon icon={Loader} />}
253 </Button>
254 ),
255 [
256 _,
257 t,
258 dirty,
259 onPressSave,
260 isUpdatingProfile,
261 displayNameTooLong,
262 descriptionTooLong,
263 ],
264 )
265
266 return (
267 <Dialog.ScrollableInner
268 label={_(msg`Edit profile`)}
269 style={[a.overflow_hidden]}
270 contentContainerStyle={[a.px_0, a.pt_0]}
271 header={
272 <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}>
273 <Dialog.HeaderText>
274 <Trans>Edit profile</Trans>
275 </Dialog.HeaderText>
276 </Dialog.Header>
277 }>
278 <View style={[a.relative]}>
279 <UserBanner banner={userBanner} onSelectNewBanner={onSelectNewBanner} />
280 <View
281 style={[
282 a.absolute,
283 {
284 top: 80,
285 left: 20,
286 width: 84,
287 height: 84,
288 borderWidth: 2,
289 borderRadius: 42,
290 borderColor: t.atoms.bg.backgroundColor,
291 },
292 ]}>
293 <EditableUserAvatar
294 size={80}
295 avatar={userAvatar}
296 onSelectNewAvatar={onSelectNewAvatar}
297 />
298 </View>
299 </View>
300 {isUpdateProfileError && (
301 <View style={[a.mt_xl]}>
302 <ErrorMessage message={cleanError(updateProfileError)} />
303 </View>
304 )}
305 {imageError !== '' && (
306 <View style={[a.mt_xl]}>
307 <ErrorMessage message={imageError} />
308 </View>
309 )}
310 <View style={[a.mt_4xl, a.px_xl, a.gap_xl]}>
311 <View>
312 <TextField.LabelText>
313 <Trans>Display name</Trans>
314 </TextField.LabelText>
315 <TextField.Root isInvalid={displayNameTooLong}>
316 <Dialog.Input
317 defaultValue={displayName}
318 onChangeText={setDisplayName}
319 label={_(msg`Display name`)}
320 placeholder={_(msg`e.g. Alice Lastname`)}
321 testID="editProfileDisplayNameInput"
322 />
323 </TextField.Root>
324 {displayNameTooLong && (
325 <Text
326 style={[
327 a.text_sm,
328 a.mt_xs,
329 a.font_semi_bold,
330 {color: t.palette.negative_400},
331 ]}>
332 <Plural
333 value={DISPLAY_NAME_MAX_GRAPHEMES}
334 other="Display name is too long. The maximum number of characters is #."
335 />
336 </Text>
337 )}
338 </View>
339
340 {verification.isVerified &&
341 verification.role === 'default' &&
342 displayName !== initialDisplayName && (
343 <Admonition type="error">
344 <Trans>
345 You are verified. You will lose your verification status if you
346 change your display name.{' '}
347 <InlineLinkText
348 label={_(
349 msg({
350 message: `Learn more`,
351 context: `english-only-resource`,
352 }),
353 )}
354 to={urls.website.blog.initialVerificationAnnouncement}>
355 <Trans context="english-only-resource">Learn more.</Trans>
356 </InlineLinkText>
357 </Trans>
358 </Admonition>
359 )}
360
361 <View>
362 <TextField.LabelText>
363 <Trans>Description</Trans>
364 </TextField.LabelText>
365 <TextField.Root isInvalid={descriptionTooLong}>
366 <Dialog.Input
367 defaultValue={description}
368 onChangeText={setDescription}
369 multiline
370 label={_(msg`Description`)}
371 placeholder={_(msg`Tell us a bit about yourself`)}
372 testID="editProfileDescriptionInput"
373 />
374 </TextField.Root>
375 {descriptionTooLong && (
376 <Text
377 style={[
378 a.text_sm,
379 a.mt_xs,
380 a.font_semi_bold,
381 {color: t.palette.negative_400},
382 ]}>
383 <Plural
384 value={DESCRIPTION_MAX_GRAPHEMES}
385 other="Description is too long. The maximum number of characters is #."
386 />
387 </Text>
388 )}
389 </View>
390 </View>
391 </Dialog.ScrollableInner>
392 )
393}