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