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