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