forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useEffect, useMemo, useState} from 'react'
2import {View} from 'react-native'
3import {type AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api'
4import {msg} from '@lingui/core/macro'
5import {useLingui} from '@lingui/react'
6import {Plural, Trans} from '@lingui/react/macro'
7
8import {cleanError} from '#/lib/strings/errors'
9import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
10import {richTextToString} from '#/lib/strings/rich-text-helpers'
11import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
12import {logger} from '#/logger'
13import {type ImageMeta} from '#/state/gallery'
14import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
15import {
16 useListCreateMutation,
17 useListMetadataMutation,
18} from '#/state/queries/list'
19import {useAgent} from '#/state/session'
20import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
21import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
22import {atoms as a, useTheme, web} from '#/alf'
23import {Button, ButtonIcon, ButtonText} from '#/components/Button'
24import * as Dialog from '#/components/Dialog'
25import * as TextField from '#/components/forms/TextField'
26import {Loader} from '#/components/Loader'
27import * as Prompt from '#/components/Prompt'
28import * as Toast from '#/components/Toast'
29import {Text} from '#/components/Typography'
30import {IS_WEB} from '#/env'
31
32const DISPLAY_NAME_MAX_GRAPHEMES = 64
33const DESCRIPTION_MAX_GRAPHEMES = 300
34
35export type InitialListValues = {
36 name?: string
37 description?: string
38 avatar?: string
39}
40
41export function CreateOrEditListDialog({
42 control,
43 list,
44 purpose,
45 onSave,
46 initialValues,
47}: {
48 control: Dialog.DialogControlProps
49 list?: AppBskyGraphDefs.ListView
50 purpose?: AppBskyGraphDefs.ListPurpose
51 onSave?: (uri: string) => void
52 initialValues?: InitialListValues
53}) {
54 const {_} = useLingui()
55 const cancelControl = Dialog.useDialogControl()
56 const [dirty, setDirty] = useState(false)
57
58 // 'You might lose unsaved changes' warning
59 useEffect(() => {
60 if (IS_WEB && dirty) {
61 const abortController = new AbortController()
62 const {signal} = abortController
63 window.addEventListener('beforeunload', evt => evt.preventDefault(), {
64 signal,
65 })
66 return () => {
67 abortController.abort()
68 }
69 }
70 }, [dirty])
71
72 const onPressCancel = useCallback(() => {
73 if (dirty) {
74 cancelControl.open()
75 } else {
76 control.close()
77 }
78 }, [dirty, control, cancelControl])
79
80 return (
81 <Dialog.Outer
82 control={control}
83 nativeOptions={{
84 preventDismiss: dirty,
85 fullHeight: true,
86 }}
87 testID="createOrEditListDialog">
88 <DialogInner
89 list={list}
90 purpose={purpose}
91 onSave={onSave}
92 setDirty={setDirty}
93 onPressCancel={onPressCancel}
94 initialValues={initialValues}
95 />
96
97 <Prompt.Basic
98 control={cancelControl}
99 title={_(msg`Discard changes?`)}
100 description={_(msg`Are you sure you want to discard your changes?`)}
101 onConfirm={() => control.close()}
102 confirmButtonCta={_(msg`Discard`)}
103 confirmButtonColor="negative"
104 />
105 </Dialog.Outer>
106 )
107}
108
109function DialogInner({
110 list,
111 purpose,
112 onSave,
113 setDirty,
114 onPressCancel,
115 initialValues,
116}: {
117 list?: AppBskyGraphDefs.ListView
118 purpose?: AppBskyGraphDefs.ListPurpose
119 onSave?: (uri: string) => void
120 setDirty: (dirty: boolean) => void
121 onPressCancel: () => void
122 initialValues?: InitialListValues
123}) {
124 const activePurpose = useMemo(() => {
125 if (list?.purpose) {
126 return list.purpose
127 }
128 if (purpose) {
129 return purpose
130 }
131 return 'app.bsky.graph.defs#curatelist'
132 }, [list, purpose])
133 const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist'
134
135 const enableSquareButtons = useEnableSquareButtons()
136
137 const {_} = useLingui()
138 const t = useTheme()
139 const agent = useAgent()
140 const control = Dialog.useDialogContext()
141 const {
142 mutateAsync: createListMutation,
143 error: createListError,
144 isError: isCreateListError,
145 isPending: isCreatingList,
146 } = useListCreateMutation()
147 const {
148 mutateAsync: updateListMutation,
149 error: updateListError,
150 isError: isUpdateListError,
151 isPending: isUpdatingList,
152 } = useListMetadataMutation()
153 const [imageError, setImageError] = useState('')
154 const [displayNameTooShort, setDisplayNameTooShort] = useState(false)
155 const initialDisplayName = list?.name || initialValues?.name || ''
156 const [displayName, setDisplayName] = useState(initialDisplayName)
157 const initialDescription =
158 list?.description || initialValues?.description || ''
159 const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => {
160 const text = list?.description ?? initialValues?.description
161 const facets = list?.descriptionFacets
162
163 if (!text || !facets) {
164 return new RichTextAPI({text: text || ''})
165 }
166
167 // We want to be working with a blank state here, so let's get the
168 // serialized version and turn it back into a RichText
169 const serialized = richTextToString(new RichTextAPI({text, facets}), false)
170
171 const richText = new RichTextAPI({text: serialized})
172 richText.detectFacetsWithoutResolution()
173
174 return richText
175 })
176
177 const initialAvatar = list?.avatar ?? initialValues?.avatar
178 const [listAvatar, setListAvatar] = useState<string | undefined | null>(
179 initialAvatar,
180 )
181 const [newListAvatar, setNewListAvatar] = useState<
182 ImageMeta | undefined | null
183 >()
184
185 // When creating with pre-filled values (from starter pack), consider dirty
186 // immediately so the Save button is enabled
187 const hasInitialValuesForCreate = !list && initialValues != null
188 const dirty =
189 hasInitialValuesForCreate ||
190 displayName !== initialDisplayName ||
191 descriptionRt.text !== initialDescription ||
192 listAvatar !== initialAvatar
193
194 useEffect(() => {
195 setDirty(dirty)
196 }, [dirty, setDirty])
197
198 const onSelectNewAvatar = useCallback(
199 (img: ImageMeta | null) => {
200 setImageError('')
201 if (img === null) {
202 setNewListAvatar(null)
203 setListAvatar(null)
204 return
205 }
206 try {
207 setNewListAvatar(img)
208 setListAvatar(img.path)
209 } catch (e: any) {
210 setImageError(cleanError(e))
211 }
212 },
213 [setNewListAvatar, setListAvatar, setImageError],
214 )
215
216 const onPressSave = useCallback(async () => {
217 setImageError('')
218 setDisplayNameTooShort(false)
219 try {
220 if (displayName.length === 0) {
221 setDisplayNameTooShort(true)
222 return
223 }
224
225 let richText = new RichTextAPI(
226 {text: descriptionRt.text.trimEnd()},
227 {cleanNewlines: true},
228 )
229
230 await richText.detectFacets(agent)
231 richText = shortenLinks(richText)
232 richText = stripInvalidMentions(richText)
233
234 if (list) {
235 await updateListMutation({
236 uri: list.uri,
237 name: displayName,
238 description: richText.text,
239 descriptionFacets: richText.facets,
240 avatar: newListAvatar,
241 })
242 Toast.show(
243 isCurateList
244 ? _(msg({message: 'User list updated', context: 'toast'}))
245 : _(msg({message: 'Moderation list updated', context: 'toast'})),
246 )
247 control.close(() => onSave?.(list.uri))
248 } else {
249 const {uri} = await createListMutation({
250 purpose: activePurpose,
251 name: displayName,
252 description: richText.text,
253 descriptionFacets: richText.facets,
254 avatar: newListAvatar,
255 })
256 Toast.show(
257 isCurateList
258 ? _(msg({message: 'User list created', context: 'toast'}))
259 : _(msg({message: 'Moderation list created', context: 'toast'})),
260 )
261 control.close(() => onSave?.(uri))
262 }
263 } catch (e: any) {
264 logger.error('Failed to create/edit list', {message: String(e)})
265 }
266 }, [
267 list,
268 createListMutation,
269 updateListMutation,
270 onSave,
271 control,
272 displayName,
273 descriptionRt,
274 newListAvatar,
275 setImageError,
276 activePurpose,
277 isCurateList,
278 agent,
279 _,
280 ])
281
282 const displayNameTooLong = isOverMaxGraphemeCount({
283 text: displayName,
284 maxCount: DISPLAY_NAME_MAX_GRAPHEMES,
285 })
286 const descriptionTooLong = isOverMaxGraphemeCount({
287 text: descriptionRt,
288 maxCount: DESCRIPTION_MAX_GRAPHEMES,
289 })
290
291 const cancelButton = useCallback(
292 () => (
293 <Button
294 label={_(msg`Cancel`)}
295 onPress={onPressCancel}
296 size="small"
297 color="primary"
298 variant="ghost"
299 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]}
300 testID="editProfileCancelBtn">
301 <ButtonText style={[a.text_md]}>
302 <Trans>Cancel</Trans>
303 </ButtonText>
304 </Button>
305 ),
306 [onPressCancel, _, enableSquareButtons],
307 )
308
309 const saveButton = useCallback(
310 () => (
311 <Button
312 label={_(msg`Save`)}
313 onPress={onPressSave}
314 disabled={
315 !dirty ||
316 isCreatingList ||
317 isUpdatingList ||
318 displayNameTooLong ||
319 descriptionTooLong
320 }
321 size="small"
322 color="primary"
323 variant="ghost"
324 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]}
325 testID="editProfileSaveBtn">
326 <ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}>
327 <Trans>Save</Trans>
328 </ButtonText>
329 {(isCreatingList || isUpdatingList) && <ButtonIcon icon={Loader} />}
330 </Button>
331 ),
332 [
333 _,
334 t,
335 dirty,
336 onPressSave,
337 isCreatingList,
338 isUpdatingList,
339 displayNameTooLong,
340 descriptionTooLong,
341 enableSquareButtons,
342 ],
343 )
344
345 const onChangeDisplayName = useCallback(
346 (text: string) => {
347 setDisplayName(text)
348 if (text.length > 0 && displayNameTooShort) {
349 setDisplayNameTooShort(false)
350 }
351 },
352 [displayNameTooShort],
353 )
354
355 const onChangeDescription = useCallback(
356 (newText: string) => {
357 const richText = new RichTextAPI({text: newText})
358 richText.detectFacetsWithoutResolution()
359
360 setDescriptionRt(richText)
361 },
362 [setDescriptionRt],
363 )
364
365 const title = list
366 ? isCurateList
367 ? _(msg`Edit user list`)
368 : _(msg`Edit moderation list`)
369 : isCurateList
370 ? _(msg`Create user list`)
371 : _(msg`Create moderation list`)
372
373 const displayNamePlaceholder = isCurateList
374 ? _(msg`e.g. Great Posters`)
375 : _(msg`e.g. Spammers`)
376
377 const descriptionPlaceholder = isCurateList
378 ? _(msg`e.g. The posters who never miss.`)
379 : _(msg`e.g. Users that repeatedly reply with ads.`)
380
381 return (
382 <Dialog.ScrollableInner
383 label={title}
384 style={[a.overflow_hidden, web({maxWidth: 500})]}
385 contentContainerStyle={[a.px_0, a.pt_0]}
386 header={
387 <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}>
388 <Dialog.HeaderText>{title}</Dialog.HeaderText>
389 </Dialog.Header>
390 }>
391 {isUpdateListError && (
392 <ErrorMessage message={cleanError(updateListError)} />
393 )}
394 {isCreateListError && (
395 <ErrorMessage message={cleanError(createListError)} />
396 )}
397 {imageError !== '' && <ErrorMessage message={imageError} />}
398 <View style={[a.pt_xl, a.px_xl, a.gap_xl]}>
399 <View>
400 <TextField.LabelText>
401 <Trans>List avatar</Trans>
402 </TextField.LabelText>
403 <View style={[a.align_start]}>
404 <EditableUserAvatar
405 size={80}
406 avatar={listAvatar}
407 onSelectNewAvatar={onSelectNewAvatar}
408 type="list"
409 />
410 </View>
411 </View>
412 <View>
413 <TextField.LabelText>
414 <Trans>List name</Trans>
415 </TextField.LabelText>
416 <TextField.Root isInvalid={displayNameTooLong || displayNameTooShort}>
417 <Dialog.Input
418 defaultValue={displayName}
419 onChangeText={onChangeDisplayName}
420 label={_(msg`Name`)}
421 placeholder={displayNamePlaceholder}
422 testID="editListNameInput"
423 />
424 </TextField.Root>
425 {(displayNameTooLong || displayNameTooShort) && (
426 <Text
427 style={[
428 a.text_sm,
429 a.mt_xs,
430 a.font_bold,
431 {color: t.palette.negative_400},
432 ]}>
433 {displayNameTooLong ? (
434 <Trans>
435 List name is too long.{' '}
436 <Plural
437 value={DISPLAY_NAME_MAX_GRAPHEMES}
438 other="The maximum number of characters is #."
439 />
440 </Trans>
441 ) : displayNameTooShort ? (
442 <Trans>List must have a name.</Trans>
443 ) : null}
444 </Text>
445 )}
446 </View>
447
448 <View>
449 <TextField.LabelText>
450 <Trans>List description</Trans>
451 </TextField.LabelText>
452 <TextField.Root isInvalid={descriptionTooLong}>
453 <Dialog.Input
454 defaultValue={descriptionRt.text}
455 onChangeText={onChangeDescription}
456 multiline
457 label={_(msg`Description`)}
458 placeholder={descriptionPlaceholder}
459 testID="editListDescriptionInput"
460 />
461 </TextField.Root>
462 {descriptionTooLong && (
463 <Text
464 style={[
465 a.text_sm,
466 a.mt_xs,
467 a.font_bold,
468 {color: t.palette.negative_400},
469 ]}>
470 <Trans>
471 List description is too long.{' '}
472 <Plural
473 value={DESCRIPTION_MAX_GRAPHEMES}
474 other="The maximum number of characters is #."
475 />
476 </Trans>
477 </Text>
478 )}
479 </View>
480 </View>
481 </Dialog.ScrollableInner>
482 )
483}