import React, {useState, useCallback, useMemo} from 'react' import { ActivityIndicator, KeyboardAvoidingView, ScrollView, StyleSheet, TextInput, TouchableOpacity, View, } from 'react-native' import { AppBskyGraphDefs, AppBskyRichtextFacet, RichText as RichTextAPI, } from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' import {Image as RNImage} from 'react-native-image-crop-picker' import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' import * as Toast from '../util/Toast' import {s, colors, gradients} from 'lib/styles' import {enforceLen} from 'lib/strings/helpers' import {compressIfNeeded} from 'lib/media/manip' import {EditableUserAvatar} from '../util/UserAvatar' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError, isNetworkError} from 'lib/strings/errors' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' import { useListCreateMutation, useListMetadataMutation, } from '#/state/queries/list' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {shortenLinks} from '#/lib/strings/rich-text-manip' import {getAgent} from '#/state/session' const MAX_NAME = 64 // todo const MAX_DESCRIPTION = 300 // todo export const snapPoints = ['fullscreen'] export function Component({ purpose, onSave, list, }: { purpose?: string onSave?: (uri: string) => void list?: AppBskyGraphDefs.ListView }) { const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const [error, setError] = useState('') const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() const {_} = useLingui() const listCreateMutation = useListCreateMutation() const listMetadataMutation = useListMetadataMutation() 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 [isProcessing, setProcessing] = useState(false) const [name, setName] = useState(list?.name || '') const [descriptionRt, setDescriptionRt] = useState(() => { 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 graphemeLength = useMemo(() => { return shortenLinks(descriptionRt).graphemeLength }, [descriptionRt]) const isDescriptionOver = graphemeLength > MAX_DESCRIPTION const [avatar, setAvatar] = useState(list?.avatar) const [newAvatar, setNewAvatar] = useState() const onDescriptionChange = useCallback( (newText: string) => { const richText = new RichTextAPI({text: newText}) richText.detectFacetsWithoutResolution() setDescriptionRt(richText) }, [setDescriptionRt], ) const onPressCancel = useCallback(() => { closeModal() }, [closeModal]) const onSelectNewAvatar = useCallback( async (img: RNImage | null) => { if (!img) { setNewAvatar(null) setAvatar(undefined) return } track('CreateList:AvatarSelected') try { const finalImg = await compressIfNeeded(img, 1000000) setNewAvatar(finalImg) setAvatar(finalImg.path) } catch (e: any) { setError(cleanError(e)) } }, [track, setNewAvatar, setAvatar, setError], ) const onPressSave = useCallback(async () => { if (isCurateList) { track('CreateList:SaveCurateList') } else { track('CreateList:SaveModList') } const nameTrimmed = name.trim() if (!nameTrimmed) { setError(_(msg`Name is required`)) return } setProcessing(true) if (error) { setError('') } try { let richText = new RichTextAPI( {text: descriptionRt.text.trimEnd()}, {cleanNewlines: true}, ) await richText.detectFacets(getAgent()) richText = shortenLinks(richText) // filter out any mention facets that didn't map to a user richText.facets = richText.facets?.filter(facet => { const mention = facet.features.find(feature => AppBskyRichtextFacet.isMention(feature), ) if (mention && !mention.did) { return false } return true }) if (list) { await listMetadataMutation.mutateAsync({ uri: list.uri, name: nameTrimmed, description: richText.text, descriptionFacets: richText.facets, avatar: newAvatar, }) Toast.show( isCurateList ? _(msg`User list updated`) : _(msg`Moderation list updated`), ) onSave?.(list.uri) } else { const res = await listCreateMutation.mutateAsync({ purpose: activePurpose, name, description: richText.text, descriptionFacets: richText.facets, avatar: newAvatar, }) Toast.show( isCurateList ? _(msg`User list created`) : _(msg`Moderation list created`), ) onSave?.(res.uri) } closeModal() } catch (e: any) { if (isNetworkError(e)) { setError( _( msg`Failed to create the list. Check your internet connection and try again.`, ), ) } else { setError(cleanError(e)) } } setProcessing(false) }, [ track, setProcessing, setError, error, onSave, closeModal, activePurpose, isCurateList, name, descriptionRt, newAvatar, list, listMetadataMutation, listCreateMutation, _, ]) return ( {isCurateList ? ( list ? ( Edit User List ) : ( New User List ) ) : list ? ( Edit Moderation List ) : ( New Moderation List )} {error !== '' && ( )} List Avatar List Name setName(enforceLen(v, MAX_NAME))} accessible={true} accessibilityLabel={_(msg`Name`)} accessibilityHint="" accessibilityLabelledBy="list-name" /> Description {graphemeLength}/{MAX_DESCRIPTION} {isProcessing ? ( ) : ( Save )} Cancel ) } const styles = StyleSheet.create({ title: { textAlign: 'center', fontWeight: 'bold', fontSize: 24, marginBottom: 18, }, labelWrapper: { flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, paddingBottom: 4, marginTop: 20, }, label: { fontWeight: 'bold', }, form: { paddingHorizontal: 6, }, textInput: { borderWidth: 1, borderRadius: 6, paddingHorizontal: 14, paddingVertical: 10, fontSize: 16, }, textArea: { borderWidth: 1, borderRadius: 6, paddingHorizontal: 12, paddingTop: 10, fontSize: 16, height: 100, textAlignVertical: 'top', }, btn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', width: '100%', borderRadius: 32, padding: 10, marginBottom: 10, }, avi: { width: 84, height: 84, borderWidth: 2, borderRadius: 42, marginTop: 4, }, errorContainer: {marginTop: 20}, })