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