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