diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index de8287a53c..ee2c0f76c7 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -267,7 +267,10 @@ export const ScrollableInner = React.forwardRef( scrollEventThrottle={50} onScroll={isAndroid ? onScroll : undefined} keyboardShouldPersistTaps="handled" - stickyHeaderIndices={header ? [0] : undefined}> + // TODO: figure out why this positions the header absolutely (rather than stickily) + // on Android. fine to disable for now, because we don't have any + // dialogs that use this that actually scroll -sfn + stickyHeaderIndices={ios(header ? [0] : undefined)}> {header} {children} diff --git a/src/components/dialogs/lists/CreateOrEditListDialog.tsx b/src/components/dialogs/lists/CreateOrEditListDialog.tsx new file mode 100644 index 0000000000..5045853f6e --- /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, 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' + +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({ + + void -} - export interface UserAddRemoveListsModal { name: 'user-add-remove-lists' subject: string @@ -46,7 +38,6 @@ export type Modal = | ContentLanguagesSettingsModal // Lists - | CreateOrEditListModal | UserAddRemoveListsModal // Bluesky access diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx index cda4e9ecfa..b448fad3a7 100644 --- a/src/view/com/composer/photos/EditImageDialog.web.tsx +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -19,7 +19,7 @@ import {type EditImageDialogProps} from './EditImageDialog' export function EditImageDialog(props: EditImageDialogProps) { return ( - + diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 79971e660a..d44f79a740 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -7,7 +7,6 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useModalControls, useModals} from '#/state/modals' import {FullWindowOverlay} from '#/components/FullWindowOverlay' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' -import * as CreateOrEditListModal from './CreateOrEditList' import * as DeleteAccountModal from './DeleteAccount' import * as InviteCodesModal from './InviteCodes' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' @@ -44,10 +43,7 @@ export function ModalsContainer() { let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let element - if (activeModal?.name === 'create-or-edit-list') { - snapPoints = CreateOrEditListModal.snapPoints - element = - } else if (activeModal?.name === 'user-add-remove-lists') { + if (activeModal?.name === 'user-add-remove-lists') { snapPoints = UserAddRemoveListsModal.snapPoints element = } else if (activeModal?.name === 'delete-account') { diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index d0799a3901..555b184b7a 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -6,7 +6,6 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type Modal as ModalIface} from '#/state/modals' import {useModalControls, useModals} from '#/state/modals' -import * as CreateOrEditListModal from './CreateOrEditList' import * as DeleteAccountModal from './DeleteAccount' import * as InviteCodesModal from './InviteCodes' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' @@ -48,9 +47,7 @@ function Modal({modal}: {modal: ModalIface}) { } let element - if (modal.name === 'create-or-edit-list') { - element = - } else if (modal.name === 'user-add-remove-lists') { + if (modal.name === 'user-add-remove-lists') { element = } else if (modal.name === 'delete-account') { element = 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) { + + + ) }