Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useEffect, useRef, useState} from 'react'
2import {Pressable, View} from 'react-native'
3import {type AppBskyActorDefs} from '@atproto/api'
4import {msg} from '@lingui/core/macro'
5import {useLingui} from '@lingui/react'
6import {Plural, Trans} from '@lingui/react/macro'
7
8import {
9 HITSLOP_10,
10 MAX_DESCRIPTION,
11 MAX_DISPLAY_NAME,
12 urls,
13} from '#/lib/constants'
14import {cleanError} from '#/lib/strings/errors'
15import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
16import {isValidWebsiteFormat} from '#/lib/strings/website'
17import {logger} from '#/logger'
18import {type ImageMeta} from '#/state/gallery'
19import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars'
20import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
21import {useProfileUpdateMutation} from '#/state/queries/profile'
22import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
23import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
24import {UserBanner} from '#/view/com/util/UserBanner'
25import {atoms as a, useTheme} from '#/alf'
26import * as tokens from '#/alf/tokens'
27import {Admonition} from '#/components/Admonition'
28import {Button, ButtonIcon, ButtonText} from '#/components/Button'
29import * as Dialog from '#/components/Dialog'
30import * as TextField from '#/components/forms/TextField'
31import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX'
32import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
33import {InlineLinkText} from '#/components/Link'
34import {Loader} from '#/components/Loader'
35import * as Prompt from '#/components/Prompt'
36import * as Toast from '#/components/Toast'
37import {Text} from '#/components/Typography'
38import {useSimpleVerificationState} from '#/components/verification'
39
40const PRONOUNS_MAX_GRAPHEMES = 20
41const WEBSITE_MAX_GRAPHEMES = 2048
42
43export function EditProfileDialog({
44 profile,
45 control,
46 onUpdate,
47}: {
48 profile: AppBskyActorDefs.ProfileViewDetailed
49 control: Dialog.DialogControlProps
50 onUpdate?: () => void
51}) {
52 const {_} = useLingui()
53 const cancelControl = Dialog.useDialogControl()
54 const [dirty, setDirty] = useState(false)
55
56 const onPressCancel = useCallback(() => {
57 if (dirty) {
58 cancelControl.open()
59 } else {
60 control.close()
61 }
62 }, [dirty, control, cancelControl])
63
64 return (
65 <Dialog.Outer
66 control={control}
67 nativeOptions={{
68 preventDismiss: dirty,
69 fullHeight: true,
70 }}
71 webOptions={{
72 onBackgroundPress: () => {
73 if (dirty) {
74 cancelControl.open()
75 } else {
76 control.close()
77 }
78 },
79 }}
80 testID="editProfileModal">
81 <DialogInner
82 profile={profile}
83 onUpdate={onUpdate}
84 setDirty={setDirty}
85 onPressCancel={onPressCancel}
86 />
87
88 <Prompt.Basic
89 control={cancelControl}
90 title={_(msg`Discard changes?`)}
91 description={_(msg`Are you sure you want to discard your changes?`)}
92 onConfirm={() => control.close()}
93 confirmButtonCta={_(msg`Discard`)}
94 confirmButtonColor="negative"
95 />
96 </Dialog.Outer>
97 )
98}
99
100function DialogInner({
101 profile,
102 onUpdate,
103 setDirty,
104 onPressCancel,
105}: {
106 profile: AppBskyActorDefs.ProfileViewDetailed
107 onUpdate?: () => void
108 setDirty: (dirty: boolean) => void
109 onPressCancel: () => void
110}) {
111 const {_} = useLingui()
112 const t = useTheme()
113 const control = Dialog.useDialogContext()
114 const enableSquareButtons = useEnableSquareButtons()
115 const verification = useSimpleVerificationState({
116 profile,
117 })
118 const {
119 mutateAsync: updateProfileMutation,
120 error: updateProfileError,
121 isError: isUpdateProfileError,
122 isPending: isUpdatingProfile,
123 } = useProfileUpdateMutation()
124 const [imageError, setImageError] = useState('')
125 const initialDisplayName = profile.displayName || ''
126 const [displayName, setDisplayName] = useState(initialDisplayName)
127 const initialDescription = profile.description || ''
128 const [description, setDescription] = useState(initialDescription)
129 const initialPronouns = profile.pronouns || ''
130 const [pronouns, setPronouns] = useState(initialPronouns)
131 const initialWebsite = profile.website || ''
132 const [website, setWebsite] = useState(initialWebsite)
133 const websiteInputRef = useRef<any>(null)
134 const [userBanner, setUserBanner] = useState<string | undefined | null>(
135 profile.banner,
136 )
137 const [userAvatar, setUserAvatar] = useState<string | undefined | null>(
138 profile.avatar,
139 )
140 const [newUserBanner, setNewUserBanner] = useState<
141 ImageMeta | undefined | null
142 >()
143 const [newUserAvatar, setNewUserAvatar] = useState<
144 ImageMeta | undefined | null
145 >()
146
147 const dirty =
148 displayName !== initialDisplayName ||
149 description !== initialDescription ||
150 userAvatar !== profile.avatar ||
151 userBanner !== profile.banner ||
152 pronouns !== initialPronouns ||
153 website !== initialWebsite
154
155 const enableSquareAvatars = useEnableSquareAvatars()
156
157 useEffect(() => {
158 setDirty(dirty)
159 }, [dirty, setDirty])
160
161 const onSelectNewAvatar = useCallback(
162 (img: ImageMeta | null) => {
163 setImageError('')
164 if (img === null) {
165 setNewUserAvatar(null)
166 setUserAvatar(null)
167 return
168 }
169 try {
170 setNewUserAvatar(img)
171 setUserAvatar(img.path)
172 } catch (e: any) {
173 setImageError(cleanError(e))
174 }
175 },
176 [setNewUserAvatar, setUserAvatar, setImageError],
177 )
178
179 const onSelectNewBanner = useCallback(
180 (img: ImageMeta | null) => {
181 setImageError('')
182 if (!img) {
183 setNewUserBanner(null)
184 setUserBanner(null)
185 return
186 }
187 try {
188 setNewUserBanner(img)
189 setUserBanner(img.path)
190 } catch (e: any) {
191 setImageError(cleanError(e))
192 }
193 },
194 [setNewUserBanner, setUserBanner, setImageError],
195 )
196
197 const onClearWebsite = useCallback(() => {
198 setWebsite('')
199 if (websiteInputRef.current) {
200 websiteInputRef.current.clear()
201 }
202 }, [setWebsite])
203
204 const onPressSave = useCallback(async () => {
205 setImageError('')
206 try {
207 await updateProfileMutation({
208 profile,
209 updates: {
210 displayName: displayName.trimEnd(),
211 description: description.trimEnd(),
212 pronouns: pronouns.trimEnd().toLowerCase(),
213 website: website.trimEnd(),
214 },
215 newUserAvatar,
216 newUserBanner,
217 })
218 control.close(() => onUpdate?.())
219 Toast.show(_(msg({message: 'Profile updated', context: 'toast'})))
220 } catch (e: any) {
221 logger.error('Failed to update user profile', {message: String(e)})
222 }
223 }, [
224 updateProfileMutation,
225 profile,
226 onUpdate,
227 control,
228 displayName,
229 description,
230 pronouns,
231 website,
232 newUserAvatar,
233 newUserBanner,
234 setImageError,
235 _,
236 ])
237
238 const displayNameTooLong = isOverMaxGraphemeCount({
239 text: displayName,
240 maxCount: MAX_DISPLAY_NAME,
241 })
242 const pronounsTooLong = isOverMaxGraphemeCount({
243 text: pronouns,
244 maxCount: PRONOUNS_MAX_GRAPHEMES,
245 })
246 const websiteTooLong = isOverMaxGraphemeCount({
247 text: website,
248 maxCount: WEBSITE_MAX_GRAPHEMES,
249 })
250 const websiteInvalidFormat = !isValidWebsiteFormat(website)
251 const descriptionTooLong = isOverMaxGraphemeCount({
252 text: description,
253 maxCount: MAX_DESCRIPTION,
254 })
255
256 const cancelButton = useCallback(
257 () => (
258 <Button
259 label={_(msg`Cancel`)}
260 onPress={onPressCancel}
261 size="small"
262 color="primary"
263 variant="ghost"
264 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]}
265 testID="editProfileCancelBtn">
266 <ButtonText style={[a.text_md]}>
267 <Trans>Cancel</Trans>
268 </ButtonText>
269 </Button>
270 ),
271 [onPressCancel, _, enableSquareButtons],
272 )
273
274 const saveButton = useCallback(
275 () => (
276 <Button
277 label={_(msg`Save`)}
278 onPress={onPressSave}
279 disabled={
280 !dirty ||
281 isUpdatingProfile ||
282 displayNameTooLong ||
283 descriptionTooLong
284 }
285 size="small"
286 color="primary"
287 variant="ghost"
288 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]}
289 testID="editProfileSaveBtn">
290 <ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}>
291 <Trans>Save</Trans>
292 </ButtonText>
293 {isUpdatingProfile && <ButtonIcon icon={Loader} />}
294 </Button>
295 ),
296 [
297 _,
298 t,
299 dirty,
300 onPressSave,
301 isUpdatingProfile,
302 displayNameTooLong,
303 descriptionTooLong,
304 enableSquareButtons,
305 ],
306 )
307
308 return (
309 <Dialog.ScrollableInner
310 label={_(msg`Edit profile`)}
311 style={[a.overflow_hidden]}
312 contentContainerStyle={[a.px_0, a.pt_0]}
313 header={
314 <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}>
315 <Dialog.HeaderText>
316 <Trans>Edit profile</Trans>
317 </Dialog.HeaderText>
318 </Dialog.Header>
319 }>
320 <View style={[a.relative]}>
321 <UserBanner banner={userBanner} onSelectNewBanner={onSelectNewBanner} />
322 <View
323 style={[
324 a.absolute,
325 {
326 top: 80,
327 left: 20,
328 width: 84,
329 height: 84,
330 borderWidth: 2,
331 borderRadius: enableSquareAvatars ? 11 : 42,
332 borderColor: t.atoms.bg.backgroundColor,
333 },
334 ]}>
335 <EditableUserAvatar
336 size={80}
337 avatar={userAvatar}
338 onSelectNewAvatar={onSelectNewAvatar}
339 />
340 </View>
341 </View>
342 {isUpdateProfileError && (
343 <View style={[a.mt_xl]}>
344 <ErrorMessage message={cleanError(updateProfileError)} />
345 </View>
346 )}
347 {imageError !== '' && (
348 <View style={[a.mt_xl]}>
349 <ErrorMessage message={imageError} />
350 </View>
351 )}
352 <View style={[a.mt_4xl, a.px_xl, a.gap_xl]}>
353 <View>
354 <TextField.LabelText>
355 <Trans>Display name</Trans>
356 </TextField.LabelText>
357 <TextField.Root isInvalid={displayNameTooLong}>
358 <Dialog.Input
359 defaultValue={displayName}
360 onChangeText={setDisplayName}
361 label={_(msg`Display name`)}
362 placeholder={_(msg`e.g. Alice Lastname`)}
363 testID="editProfileDisplayNameInput"
364 />
365 </TextField.Root>
366 {displayNameTooLong && (
367 <Text
368 style={[
369 a.text_sm,
370 a.mt_xs,
371 a.font_semi_bold,
372 {color: t.palette.negative_400},
373 ]}>
374 <Plural
375 value={MAX_DISPLAY_NAME}
376 other="Display name is too long. The maximum number of characters is #."
377 />
378 </Text>
379 )}
380 </View>
381
382 {verification.isVerified &&
383 verification.role === 'default' &&
384 displayName !== initialDisplayName && (
385 <Admonition type="error">
386 <Trans>
387 You are verified. You will lose your verification status if you
388 change your display name.{' '}
389 <InlineLinkText
390 label={_(
391 msg({
392 message: `Learn more`,
393 context: `english-only-resource`,
394 }),
395 )}
396 to={urls.website.blog.initialVerificationAnnouncement}>
397 <Trans context="english-only-resource">Learn more.</Trans>
398 </InlineLinkText>
399 </Trans>
400 </Admonition>
401 )}
402
403 <View>
404 <TextField.LabelText>
405 <Trans>Description</Trans>
406 </TextField.LabelText>
407 <TextField.Root isInvalid={descriptionTooLong}>
408 <Dialog.Input
409 defaultValue={description}
410 onChangeText={setDescription}
411 multiline
412 label={_(msg`Description`)}
413 placeholder={_(msg`Tell us a bit about yourself`)}
414 testID="editProfileDescriptionInput"
415 />
416 </TextField.Root>
417 {descriptionTooLong && (
418 <Text
419 style={[
420 a.text_sm,
421 a.mt_xs,
422 a.font_semi_bold,
423 {color: t.palette.negative_400},
424 ]}>
425 <Plural
426 value={MAX_DESCRIPTION}
427 other="Description is too long. The maximum number of characters is #."
428 />
429 </Text>
430 )}
431 </View>
432
433 <View>
434 <TextField.LabelText>
435 <Trans>Pronouns</Trans>
436 </TextField.LabelText>
437 <TextField.Root isInvalid={pronounsTooLong}>
438 <Dialog.Input
439 defaultValue={pronouns}
440 onChangeText={setPronouns}
441 label={_(msg`Pronouns`)}
442 placeholder={_(msg`e.g. she/her`)}
443 testID="editProfilePronounsInput"
444 />
445 </TextField.Root>
446 {pronounsTooLong && (
447 <Text
448 style={[
449 a.text_sm,
450 a.mt_xs,
451 a.font_bold,
452 {color: t.palette.negative_400},
453 ]}>
454 <Plural
455 value={PRONOUNS_MAX_GRAPHEMES}
456 other="The maximum number of characters is #."
457 />
458 </Text>
459 )}
460 </View>
461
462 <View>
463 <TextField.LabelText>
464 <Trans>Website</Trans>
465 </TextField.LabelText>
466 <View style={[a.w_full, a.relative]}>
467 <TextField.Root isInvalid={websiteTooLong || websiteInvalidFormat}>
468 {website && <TextField.Icon icon={Globe} />}
469 <Dialog.Input
470 inputRef={websiteInputRef}
471 defaultValue={website}
472 onChangeText={setWebsite}
473 label={_(msg`EditWebsite`)}
474 placeholder={_(msg`URL`)}
475 testID="editProfileWebsiteInput"
476 autoCapitalize="none"
477 keyboardType="url"
478 style={[
479 website
480 ? {
481 paddingRight: tokens.space._5xl,
482 }
483 : {},
484 ]}
485 />
486 </TextField.Root>
487
488 {website && (
489 <View
490 style={[
491 a.absolute,
492 a.z_10,
493 a.my_auto,
494 a.inset_0,
495 a.justify_center,
496 a.pr_sm,
497 {left: 'auto'},
498 ]}>
499 <Pressable
500 testID="clearWebsiteBtn"
501 onPress={onClearWebsite}
502 accessibilityLabel={_(msg`Clear website`)}
503 accessibilityHint={_(msg`Removes the website URL`)}
504 hitSlop={HITSLOP_10}
505 style={[
506 a.flex_row,
507 a.align_center,
508 a.justify_center,
509 {
510 width: tokens.space._2xl,
511 height: tokens.space._2xl,
512 },
513 a.rounded_full,
514 ]}>
515 <CircleX
516 width={tokens.space.lg}
517 style={{color: t.palette.contrast_600}}
518 />
519 </Pressable>
520 </View>
521 )}
522 </View>
523 {websiteTooLong && (
524 <Text
525 style={[
526 a.text_sm,
527 a.mt_xs,
528 a.font_bold,
529 {color: t.palette.negative_400},
530 ]}>
531 <Plural
532 value={WEBSITE_MAX_GRAPHEMES}
533 other="Website is too long. The maximum number of characters is #."
534 />
535 </Text>
536 )}
537 {websiteInvalidFormat && (
538 <Text
539 style={[
540 a.text_sm,
541 a.mt_xs,
542 a.font_bold,
543 {color: t.palette.negative_400},
544 ]}>
545 <Trans>
546 Website must be a valid URI (e.g., https://bsky.app)
547 </Trans>
548 </Text>
549 )}
550 </View>
551 </View>
552 </Dialog.ScrollableInner>
553 )
554}