Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
13 kB · 466 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467import {useCallback, useEffect, useMemo, useState} from 'react'import {useWindowDimensions, View} from 'react-native'import {type AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api'import {msg, Plural, Trans} from '@lingui/macro'import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'import {richTextToString} from '#/lib/strings/rich-text-helpers'import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'import {logger} from '#/logger'import {type ImageMeta} from '#/state/gallery'import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'import { useListCreateMutation, useListMetadataMutation,} from '#/state/queries/list'import {useAgent} from '#/state/session'import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'import * as Toast from '#/view/com/util/Toast'import {EditableUserAvatar} from '#/view/com/util/UserAvatar'import {atoms as a, useTheme, web} from '#/alf'import {Button, ButtonIcon, ButtonText} from '#/components/Button'import * as Dialog from '#/components/Dialog'import * as TextField from '#/components/forms/TextField'import {Loader} from '#/components/Loader'import * as Prompt from '#/components/Prompt'import {Text} from '#/components/Typography'import {IS_WEB} from '#/env'
const DISPLAY_NAME_MAX_GRAPHEMES = 64const DESCRIPTION_MAX_GRAPHEMES = 300
export function CreateOrEditListDialog({ control, list, purpose, onSave,}: { control: Dialog.DialogControlProps list?: AppBskyGraphDefs.ListView purpose?: AppBskyGraphDefs.ListPurpose onSave?: (uri: string) => void}) { const {_} = useLingui() const cancelControl = Dialog.useDialogControl() const [dirty, setDirty] = useState(false) const {height} = useWindowDimensions()
// 'You might lose unsaved changes' warning useEffect(() => { if (IS_WEB && dirty) { const abortController = new AbortController() const {signal} = abortController window.addEventListener('beforeunload', evt => evt.preventDefault(), { signal, }) return () => { abortController.abort() } } }, [dirty])
const onPressCancel = useCallback(() => { if (dirty) { cancelControl.open() } else { control.close() } }, [dirty, control, cancelControl])
return ( <Dialog.Outer control={control} nativeOptions={{ preventDismiss: dirty, minHeight: height, }} testID="createOrEditListDialog"> <DialogInner list={list} purpose={purpose} onSave={onSave} setDirty={setDirty} onPressCancel={onPressCancel} />
<Prompt.Basic control={cancelControl} title={_(msg`Discard changes?`)} description={_(msg`Are you sure you want to discard your changes?`)} onConfirm={() => control.close()} confirmButtonCta={_(msg`Discard`)} confirmButtonColor="negative" /> </Dialog.Outer> )}
function DialogInner({ list, purpose, onSave, setDirty, onPressCancel,}: { list?: AppBskyGraphDefs.ListView purpose?: AppBskyGraphDefs.ListPurpose onSave?: (uri: string) => void setDirty: (dirty: boolean) => void onPressCancel: () => void}) { const activePurpose = useMemo(() => { if (list?.purpose) { return list.purpose } if (purpose) { return purpose } return 'app.bsky.graph.defs#curatelist' }, [list, purpose]) const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist'
const enableSquareButtons = useEnableSquareButtons()
const {_} = useLingui() const t = useTheme() const agent = useAgent() const control = Dialog.useDialogContext() const { mutateAsync: createListMutation, error: createListError, isError: isCreateListError, isPending: isCreatingList, } = useListCreateMutation() const { mutateAsync: updateListMutation, error: updateListError, isError: isUpdateListError, isPending: isUpdatingList, } = useListMetadataMutation() const [imageError, setImageError] = useState('') const [displayNameTooShort, setDisplayNameTooShort] = useState(false) const initialDisplayName = list?.name || '' const [displayName, setDisplayName] = useState(initialDisplayName) const initialDescription = list?.description || '' const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => { const text = list?.description const facets = list?.descriptionFacets
if (!text || !facets) { return new RichTextAPI({text: text || ''}) }
// We want to be working with a blank state here, so let's get the // serialized version and turn it back into a RichText const serialized = richTextToString(new RichTextAPI({text, facets}), false)
const richText = new RichTextAPI({text: serialized}) richText.detectFacetsWithoutResolution()
return richText })
const [listAvatar, setListAvatar] = useState<string | undefined | null>( list?.avatar, ) const [newListAvatar, setNewListAvatar] = useState< ImageMeta | undefined | null >()
const dirty = displayName !== initialDisplayName || descriptionRt.text !== initialDescription || listAvatar !== list?.avatar
useEffect(() => { setDirty(dirty) }, [dirty, setDirty])
const onSelectNewAvatar = useCallback( (img: ImageMeta | null) => { setImageError('') if (img === null) { setNewListAvatar(null) setListAvatar(null) return } try { setNewListAvatar(img) setListAvatar(img.path) } catch (e: any) { setImageError(cleanError(e)) } }, [setNewListAvatar, setListAvatar, setImageError], )
const onPressSave = useCallback(async () => { setImageError('') setDisplayNameTooShort(false) try { if (displayName.length === 0) { setDisplayNameTooShort(true) return }
let richText = new RichTextAPI( {text: descriptionRt.text.trimEnd()}, {cleanNewlines: true}, )
await richText.detectFacets(agent) richText = shortenLinks(richText) richText = stripInvalidMentions(richText)
if (list) { await updateListMutation({ uri: list.uri, name: displayName, description: richText.text, descriptionFacets: richText.facets, avatar: newListAvatar, }) Toast.show( isCurateList ? _(msg({message: 'User list updated', context: 'toast'})) : _(msg({message: 'Moderation list updated', context: 'toast'})), ) control.close(() => onSave?.(list.uri)) } else { const {uri} = await createListMutation({ purpose: activePurpose, name: displayName, description: richText.text, descriptionFacets: richText.facets, avatar: newListAvatar, }) Toast.show( isCurateList ? _(msg({message: 'User list created', context: 'toast'})) : _(msg({message: 'Moderation list created', context: 'toast'})), ) control.close(() => onSave?.(uri)) } } catch (e: any) { logger.error('Failed to create/edit list', {message: String(e)}) } }, [ list, createListMutation, updateListMutation, onSave, control, displayName, descriptionRt, newListAvatar, setImageError, activePurpose, isCurateList, agent, _, ])
const displayNameTooLong = isOverMaxGraphemeCount({ text: displayName, maxCount: DISPLAY_NAME_MAX_GRAPHEMES, }) const descriptionTooLong = isOverMaxGraphemeCount({ text: descriptionRt, maxCount: DESCRIPTION_MAX_GRAPHEMES, })
const cancelButton = useCallback( () => ( <Button label={_(msg`Cancel`)} onPress={onPressCancel} size="small" color="primary" variant="ghost" style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]} testID="editProfileCancelBtn"> <ButtonText style={[a.text_md]}> <Trans>Cancel</Trans> </ButtonText> </Button> ), [onPressCancel, _, enableSquareButtons], )
const saveButton = useCallback( () => ( <Button label={_(msg`Save`)} onPress={onPressSave} disabled={ !dirty || isCreatingList || isUpdatingList || displayNameTooLong || descriptionTooLong } size="small" color="primary" variant="ghost" style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]} testID="editProfileSaveBtn"> <ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}> <Trans>Save</Trans> </ButtonText> {(isCreatingList || isUpdatingList) && <ButtonIcon icon={Loader} />} </Button> ), [ _, t, dirty, onPressSave, isCreatingList, isUpdatingList, displayNameTooLong, descriptionTooLong, enableSquareButtons, ], )
const onChangeDisplayName = useCallback( (text: string) => { setDisplayName(text) if (text.length > 0 && displayNameTooShort) { setDisplayNameTooShort(false) } }, [displayNameTooShort], )
const onChangeDescription = useCallback( (newText: string) => { const richText = new RichTextAPI({text: newText}) richText.detectFacetsWithoutResolution()
setDescriptionRt(richText) }, [setDescriptionRt], )
const title = list ? isCurateList ? _(msg`Edit user list`) : _(msg`Edit moderation list`) : isCurateList ? _(msg`Create user list`) : _(msg`Create moderation list`)
const displayNamePlaceholder = isCurateList ? _(msg`e.g. Great Posters`) : _(msg`e.g. Spammers`)
const descriptionPlaceholder = isCurateList ? _(msg`e.g. The posters who never miss.`) : _(msg`e.g. Users that repeatedly reply with ads.`)
return ( <Dialog.ScrollableInner label={title} style={[a.overflow_hidden, web({maxWidth: 500})]} contentContainerStyle={[a.px_0, a.pt_0]} header={ <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}> <Dialog.HeaderText>{title}</Dialog.HeaderText> </Dialog.Header> }> {isUpdateListError && ( <ErrorMessage message={cleanError(updateListError)} /> )} {isCreateListError && ( <ErrorMessage message={cleanError(createListError)} /> )} {imageError !== '' && <ErrorMessage message={imageError} />} <View style={[a.pt_xl, a.px_xl, a.gap_xl]}> <View> <TextField.LabelText> <Trans>List avatar</Trans> </TextField.LabelText> <View style={[a.align_start]}> <EditableUserAvatar size={80} avatar={listAvatar} onSelectNewAvatar={onSelectNewAvatar} type="list" /> </View> </View> <View> <TextField.LabelText> <Trans>List name</Trans> </TextField.LabelText> <TextField.Root isInvalid={displayNameTooLong || displayNameTooShort}> <Dialog.Input defaultValue={displayName} onChangeText={onChangeDisplayName} label={_(msg`Name`)} placeholder={displayNamePlaceholder} testID="editListNameInput" /> </TextField.Root> {(displayNameTooLong || displayNameTooShort) && ( <Text style={[ a.text_sm, a.mt_xs, a.font_bold, {color: t.palette.negative_400}, ]}> {displayNameTooLong ? ( <Trans> List name is too long.{' '} <Plural value={DISPLAY_NAME_MAX_GRAPHEMES} other="The maximum number of characters is #." /> </Trans> ) : displayNameTooShort ? ( <Trans>List must have a name.</Trans> ) : null} </Text> )} </View>
<View> <TextField.LabelText> <Trans>List description</Trans> </TextField.LabelText> <TextField.Root isInvalid={descriptionTooLong}> <Dialog.Input defaultValue={descriptionRt.text} onChangeText={onChangeDescription} multiline label={_(msg`Description`)} placeholder={descriptionPlaceholder} testID="editListDescriptionInput" /> </TextField.Root> {descriptionTooLong && ( <Text style={[ a.text_sm, a.mt_xs, a.font_bold, {color: t.palette.negative_400}, ]}> <Trans> List description is too long.{' '} <Plural value={DESCRIPTION_MAX_GRAPHEMES} other="The maximum number of characters is #." /> </Trans> </Text> )} </View> </View> </Dialog.ScrollableInner> )}