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