2beb63fb2d
The two RichText classes are not mutually assignable - `UnicodeString` has a private field and the SDK brands `did`/`uri` as template literal types - so every producer and consumer of a RichText instance has to move in one step. `detectFacets` now takes a lex client instead of the legacy agent, which is what removes the last hard agent dependency from these files. Handle resolution is an appview job, so the appview client is threaded in: through `useAppviewClient` in the hooks and dialogs, and through a new `appviewClient` option on `apilib.post` (Composer already had the client to hand). The rest of the post pipeline still writes through the agent. Facet feature checks move from the `AppBskyRichtextFacet` validators to the generated `#/lexicons` schemas, matching how the rest of the app narrows lexicon types. Display sinks still read facets off `@atproto/api` view types, which are the same lexicon but typed with plain strings. `asSdkFacets` widens them at those call sites and goes away once the view types come from the SDK too.
488 lines
14 KiB
TypeScript
488 lines
14 KiB
TypeScript
import {useCallback, useEffect, useMemo, useState} from 'react'
|
|
import {View} from 'react-native'
|
|
import {type AppBskyGraphDefs} from '@atproto/api'
|
|
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
|
|
import {msg} from '@lingui/core/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
import {Plural, Trans} from '@lingui/react/macro'
|
|
|
|
import {cleanError} from '#/lib/strings/errors'
|
|
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
|
|
import {asSdkFacets, 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 {
|
|
useListCreateMutation,
|
|
useListMetadataMutation,
|
|
} from '#/state/queries/list'
|
|
import {useAppviewClient} from '#/state/session'
|
|
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
|
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 * as Toast from '#/components/Toast'
|
|
import {Text} from '#/components/Typography'
|
|
import {IS_WEB} from '#/env'
|
|
|
|
const DISPLAY_NAME_MAX_GRAPHEMES = 64
|
|
const DESCRIPTION_MAX_GRAPHEMES = 300
|
|
|
|
export type InitialListValues = {
|
|
name?: string
|
|
description?: string
|
|
avatar?: string
|
|
}
|
|
|
|
export function CreateOrEditListDialog({
|
|
control,
|
|
list,
|
|
purpose,
|
|
onSave,
|
|
initialValues,
|
|
}: {
|
|
control: Dialog.DialogControlProps
|
|
list?: AppBskyGraphDefs.ListView
|
|
purpose?: AppBskyGraphDefs.ListPurpose
|
|
onSave?: (uri: string) => void
|
|
initialValues?: InitialListValues
|
|
}) {
|
|
const {_} = useLingui()
|
|
const cancelControl = Dialog.useDialogControl()
|
|
const [dirty, setDirty] = useState(false)
|
|
|
|
// '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,
|
|
fullHeight: true,
|
|
}}
|
|
testID="createOrEditListDialog">
|
|
<DialogInner
|
|
list={list}
|
|
purpose={purpose}
|
|
onSave={onSave}
|
|
setDirty={setDirty}
|
|
onPressCancel={onPressCancel}
|
|
initialValues={initialValues}
|
|
/>
|
|
|
|
<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,
|
|
initialValues,
|
|
}: {
|
|
list?: AppBskyGraphDefs.ListView
|
|
purpose?: AppBskyGraphDefs.ListPurpose
|
|
onSave?: (uri: string) => void
|
|
setDirty: (dirty: boolean) => void
|
|
onPressCancel: () => void
|
|
initialValues?: InitialListValues
|
|
}) {
|
|
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 {_} = useLingui()
|
|
const t = useTheme()
|
|
/*
|
|
* Facet/mention resolution is an appview job - it resolves handles through
|
|
* the appview, and the public fallback keeps it working when logged out.
|
|
*/
|
|
const appviewClient = useAppviewClient()
|
|
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 || initialValues?.name || ''
|
|
const [displayName, setDisplayName] = useState(initialDisplayName)
|
|
const initialDescription =
|
|
list?.description || initialValues?.description || ''
|
|
const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => {
|
|
const text = list?.description ?? initialValues?.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: asSdkFacets(facets)}),
|
|
false,
|
|
)
|
|
|
|
const richText = new RichTextAPI({text: serialized})
|
|
richText.detectFacetsWithoutResolution()
|
|
|
|
return richText
|
|
})
|
|
|
|
const initialAvatar = list?.avatar ?? initialValues?.avatar
|
|
const [listAvatar, setListAvatar] = useState<string | undefined | null>(
|
|
initialAvatar,
|
|
)
|
|
const [newListAvatar, setNewListAvatar] = useState<
|
|
ImageMeta | undefined | null
|
|
>()
|
|
|
|
// When creating with pre-filled values (from starter pack), consider dirty
|
|
// immediately so the Save button is enabled
|
|
const hasInitialValuesForCreate = !list && initialValues != null
|
|
const dirty =
|
|
hasInitialValuesForCreate ||
|
|
displayName !== initialDisplayName ||
|
|
descriptionRt.text !== initialDescription ||
|
|
listAvatar !== initialAvatar
|
|
|
|
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(appviewClient)
|
|
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,
|
|
appviewClient,
|
|
_,
|
|
])
|
|
|
|
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={[a.rounded_full]}
|
|
testID="editProfileCancelBtn">
|
|
<ButtonText style={[a.text_md]}>
|
|
<Trans>Cancel</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
),
|
|
[onPressCancel, _],
|
|
)
|
|
|
|
const saveButton = useCallback(
|
|
() => (
|
|
<Button
|
|
label={_(msg`Save`)}
|
|
onPress={onPressSave}
|
|
disabled={
|
|
!dirty ||
|
|
isCreatingList ||
|
|
isUpdatingList ||
|
|
displayNameTooLong ||
|
|
descriptionTooLong
|
|
}
|
|
size="small"
|
|
color="primary"
|
|
variant="ghost"
|
|
style={[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,
|
|
],
|
|
)
|
|
|
|
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>
|
|
)
|
|
}
|