diff --git a/src/components/dialogs/lists/CreateOrEditListDialog.tsx b/src/components/dialogs/lists/CreateOrEditListDialog.tsx
new file mode 100644
index 0000000000..86bd8a8830
--- /dev/null
+++ b/src/components/dialogs/lists/CreateOrEditListDialog.tsx
@@ -0,0 +1,454 @@
+import {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 {useWarnMaxGraphemeCount} 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 {isWeb} from '#/platform/detection'
+import {type ImageMeta} from '#/state/gallery'
+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} 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'
+
+const DISPLAY_NAME_MAX_GRAPHEMES = 64
+const 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 (isWeb && 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 (
+
+
+
+ control.close()}
+ confirmButtonCta={_(msg`Discard`)}
+ confirmButtonColor="negative"
+ />
+
+ )
+}
+
+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 {_} = 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(() => {
+ 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(
+ 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 = useWarnMaxGraphemeCount({
+ text: displayName,
+ maxCount: DISPLAY_NAME_MAX_GRAPHEMES,
+ })
+ const descriptionTooLong = useWarnMaxGraphemeCount({
+ text: descriptionRt,
+ maxCount: DESCRIPTION_MAX_GRAPHEMES,
+ })
+
+ const cancelButton = useCallback(
+ () => (
+
+ ),
+ [onPressCancel, _],
+ )
+
+ const saveButton = useCallback(
+ () => (
+
+ ),
+ [
+ _,
+ 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`)
+
+ return (
+
+ {title}
+
+ }>
+ {isUpdateListError && (
+
+ )}
+ {isCreateListError && (
+
+ )}
+ {imageError !== '' && }
+
+
+
+ List avatar
+
+
+
+
+
+
+
+ List name
+
+
+
+
+ {(displayNameTooLong || displayNameTooShort) && (
+
+ {displayNameTooLong ? (
+
+ List name is too long.{' '}
+
+
+ ) : displayNameTooShort ? (
+ List must have a name.
+ ) : null}
+
+ )}
+
+
+
+
+ List description
+
+
+
+
+ {descriptionTooLong && (
+
+
+ List description is too long.{' '}
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts
index 61ad4e85ba..3f7c0d4782 100644
--- a/src/lib/strings/helpers.ts
+++ b/src/lib/strings/helpers.ts
@@ -1,6 +1,9 @@
import {useCallback, useMemo} from 'react'
+import {type RichText} from '@atproto/api'
import Graphemer from 'graphemer'
+import {shortenLinks} from './rich-text-manip'
+
export function enforceLen(
str: string,
len: number,
@@ -45,13 +48,17 @@ export function useWarnMaxGraphemeCount({
text,
maxCount,
}: {
- text: string
+ text: string | RichText
maxCount: number
}) {
const splitter = useMemo(() => new Graphemer(), [])
return useMemo(() => {
- return splitter.countGraphemes(text) > maxCount
+ if (typeof text === 'string') {
+ return splitter.countGraphemes(text) > maxCount
+ } else {
+ return shortenLinks(text).graphemeLength > maxCount
+ }
}, [splitter, maxCount, text])
}
diff --git a/src/screens/Profile/Header/EditProfileDialog.tsx b/src/screens/Profile/Header/EditProfileDialog.tsx
index eb9e9179df..b1c52d67d7 100644
--- a/src/screens/Profile/Header/EditProfileDialog.tsx
+++ b/src/screens/Profile/Header/EditProfileDialog.tsx
@@ -1,5 +1,5 @@
import {useCallback, useEffect, useState} from 'react'
-import {Dimensions, View} from 'react-native'
+import {useWindowDimensions, View} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -28,8 +28,6 @@ import {useSimpleVerificationState} from '#/components/verification'
const DISPLAY_NAME_MAX_GRAPHEMES = 64
const DESCRIPTION_MAX_GRAPHEMES = 256
-const SCREEN_HEIGHT = Dimensions.get('window').height
-
export function EditProfileDialog({
profile,
control,
@@ -42,6 +40,7 @@ export function EditProfileDialog({
const {_} = useLingui()
const cancelControl = Dialog.useDialogControl()
const [dirty, setDirty] = useState(false)
+ const {height} = useWindowDimensions()
const onPressCancel = useCallback(() => {
if (dirty) {
@@ -56,7 +55,7 @@ export function EditProfileDialog({
control={control}
nativeOptions={{
preventDismiss: dirty,
- minHeight: SCREEN_HEIGHT,
+ minHeight: height,
}}
webOptions={{
onBackgroundPress: () => {
@@ -186,8 +185,7 @@ function DialogInner({
newUserAvatar,
newUserBanner,
})
- onUpdate?.()
- control.close()
+ control.close(() => onUpdate?.())
Toast.show(_(msg({message: 'Profile updated', context: 'toast'})))
} catch (e: any) {
logger.error('Failed to update user profile', {message: String(e)})
@@ -369,7 +367,7 @@ function DialogInner({
defaultValue={description}
onChangeText={setDescription}
multiline
- label={_(msg`Display name`)}
+ label={_(msg`Description`)}
placeholder={_(msg`Tell us a bit about yourself`)}
testID="editProfileDescriptionInput"
/>
diff --git a/src/screens/ProfileList/components/MoreOptionsMenu.tsx b/src/screens/ProfileList/components/MoreOptionsMenu.tsx
index 17ca43a823..a275854ff7 100644
--- a/src/screens/ProfileList/components/MoreOptionsMenu.tsx
+++ b/src/screens/ProfileList/components/MoreOptionsMenu.tsx
@@ -8,7 +8,6 @@ import {shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
-import {useModalControls} from '#/state/modals'
import {
useListBlockMutation,
useListDeleteMutation,
@@ -18,6 +17,7 @@ import {useRemoveFeedMutation} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
+import {CreateOrEditListDialog} from '#/components/dialogs/lists/CreateOrEditListDialog'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLink} from '#/components/icons/ChainLink'
import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
@@ -44,7 +44,7 @@ export function MoreOptionsMenu({
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
- const {openModal} = useModalControls()
+ const editListDialogControl = useDialogControl()
const deleteListPromptControl = useDialogControl()
const reportDialogControl = useReportDialogControl()
const navigation = useNavigation()
@@ -80,13 +80,6 @@ export function MoreOptionsMenu({
}
}
- const onPressEdit = () => {
- openModal({
- name: 'create-or-edit-list',
- list,
- })
- }
-
const onPressDelete = async () => {
await deleteList({uri: list.uri})
@@ -201,7 +194,7 @@ export function MoreOptionsMenu({
+ onPress={editListDialogControl.open}>
Edit list details
@@ -275,6 +268,8 @@ export function MoreOptionsMenu({
+
+
+
diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx
index bcda97dc5a..b165979c2c 100644
--- a/src/view/screens/Lists.tsx
+++ b/src/view/screens/Lists.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import {useCallback} from 'react'
import {AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -10,11 +10,12 @@ import {
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
-import {useModalControls} from '#/state/modals'
import {useSetMinimalShellMode} from '#/state/shell'
import {MyLists} from '#/view/com/lists/MyLists'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {CreateOrEditListDialog} from '#/components/dialogs/lists/CreateOrEditListDialog'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import * as Layout from '#/components/Layout'
@@ -23,30 +24,18 @@ export function ListsScreen({}: Props) {
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const navigation = useNavigation()
- const {openModal} = useModalControls()
const requireEmailVerification = useRequireEmailVerification()
+ const createListDialogControl = useDialogControl()
useFocusEffect(
- React.useCallback(() => {
+ useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
- const onPressNewList = React.useCallback(() => {
- openModal({
- name: 'create-or-edit-list',
- purpose: 'app.bsky.graph.defs#curatelist',
- onSave: (uri: string) => {
- try {
- const urip = new AtUri(uri)
- navigation.navigate('ProfileList', {
- name: urip.hostname,
- rkey: urip.rkey,
- })
- } catch {}
- },
- })
- }, [openModal, navigation])
+ const onPressNewList = useCallback(() => {
+ createListDialogControl.open()
+ }, [createListDialogControl])
const wrappedOnPressNewList = requireEmailVerification(onPressNewList, {
instructions: [
@@ -56,6 +45,19 @@ export function ListsScreen({}: Props) {
],
})
+ const onCreateList = useCallback(
+ (uri: string) => {
+ try {
+ const urip = new AtUri(uri)
+ navigation.navigate('ProfileList', {
+ name: urip.hostname,
+ rkey: urip.rkey,
+ })
+ } catch {}
+ },
+ [navigation],
+ )
+
return (
@@ -78,7 +80,14 @@ export function ListsScreen({}: Props) {
+
+
+
)
}
diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx
index 23ed492f64..1f786d88bc 100644
--- a/src/view/screens/ModerationModlists.tsx
+++ b/src/view/screens/ModerationModlists.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import {useCallback} from 'react'
import {AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -10,11 +10,12 @@ import {
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {type NavigationProp} from '#/lib/routes/types'
-import {useModalControls} from '#/state/modals'
import {useSetMinimalShellMode} from '#/state/shell'
import {MyLists} from '#/view/com/lists/MyLists'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {CreateOrEditListDialog} from '#/components/dialogs/lists/CreateOrEditListDialog'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import * as Layout from '#/components/Layout'
@@ -23,30 +24,18 @@ export function ModerationModlistsScreen({}: Props) {
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const navigation = useNavigation()
- const {openModal} = useModalControls()
const requireEmailVerification = useRequireEmailVerification()
+ const createListDialogControl = useDialogControl()
useFocusEffect(
- React.useCallback(() => {
+ useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
- const onPressNewList = React.useCallback(() => {
- openModal({
- name: 'create-or-edit-list',
- purpose: 'app.bsky.graph.defs#modlist',
- onSave: (uri: string) => {
- try {
- const urip = new AtUri(uri)
- navigation.navigate('ProfileList', {
- name: urip.hostname,
- rkey: urip.rkey,
- })
- } catch {}
- },
- })
- }, [openModal, navigation])
+ const onPressNewList = useCallback(() => {
+ createListDialogControl.open()
+ }, [createListDialogControl])
const wrappedOnPressNewList = requireEmailVerification(onPressNewList, {
instructions: [
@@ -56,6 +45,19 @@ export function ModerationModlistsScreen({}: Props) {
],
})
+ const onCreateList = useCallback(
+ (uri: string) => {
+ try {
+ const urip = new AtUri(uri)
+ navigation.navigate('ProfileList', {
+ name: urip.hostname,
+ rkey: urip.rkey,
+ })
+ } catch {}
+ },
+ [navigation],
+ )
+
return (
@@ -78,7 +80,14 @@ export function ModerationModlistsScreen({}: Props) {
+
+
+
)
}