diff --git a/src/components/Dialog/shared.tsx b/src/components/Dialog/shared.tsx index 40d0408788..d13ea845ae 100644 --- a/src/components/Dialog/shared.tsx +++ b/src/components/Dialog/shared.tsx @@ -28,6 +28,8 @@ export function Header({ + ]} + onLayout={onLayout}> {renderLeft && ( {renderLeft()} )} diff --git a/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx b/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx new file mode 100644 index 0000000000..8a9206695b --- /dev/null +++ b/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx @@ -0,0 +1,390 @@ +import {useCallback, useMemo, useState} from 'react' +import {useWindowDimensions, View} from 'react-native' +import {type AppBskyGraphDefs, type ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {cleanError} from '#/lib/strings/errors' +import {sanitizeHandle} from '#/lib/strings/handles' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import { + getMembership, + type ListMembersip, + useDangerousListMembershipsQuery, + useListMembershipAddMutation, + useListMembershipRemoveMutation, +} from '#/state/queries/list-memberships' +import {useMyListsQuery} from '#/state/queries/my-lists' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} 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 {BulletList_Stroke2_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export function UserAddRemoveListsDialog({ + control, + profile, + onChange, +}: { + control: Dialog.DialogControlProps + profile: bsky.profile.AnyProfileView + onChange?: ( + type: 'add' | 'remove', + list: AppBskyGraphDefs.ListView, + ) => void | undefined +}) { + const {height} = useWindowDimensions() + return ( + + + + ) +} + +const LOADING = {type: 'loading', _reactKey: '__loading__'} as const +const EMPTY = {type: 'empty', _reactKey: '__empty__'} as const +const ERROR_ITEM = {type: 'error', _reactKey: '__error__'} as const + +type ListItem = + | typeof LOADING + | typeof EMPTY + | typeof ERROR_ITEM + | { + type: 'section-header' + title: string + _reactKey: string + } + | { + type: 'list' + list: AppBskyGraphDefs.ListView + } + +function DialogInner({ + profile, + onChange, +}: { + profile: bsky.profile.AnyProfileView + onChange?: ( + type: 'add' | 'remove', + list: AppBskyGraphDefs.ListView, + ) => void | undefined +}) { + const t = useTheme() + const {_} = useLingui() + const control = Dialog.useDialogContext() + const moderationOpts = useModerationOpts() + const [headerHeight, setHeaderHeight] = useState(50) + const {data, isPending, isError, error} = useMyListsQuery('all') + const {data: memberships} = useDangerousListMembershipsQuery() + const isEmpty = !isPending && !data?.length + + const items = useMemo(() => { + let _items: ListItem[] = [] + if (isError && isEmpty) { + _items = _items.concat([ERROR_ITEM]) + } + if (isPending || !moderationOpts) { + _items = _items.concat([LOADING]) + } else if (isEmpty) { + _items = _items.concat([EMPTY]) + } else if (data) { + const curateLists = data.filter( + list => list.purpose === 'app.bsky.graph.defs#curatelist', + ) + const starterPacks = data.filter( + list => list.purpose === 'app.bsky.graph.defs#referencelist', + ) + const modLists = data.filter( + list => list.purpose === 'app.bsky.graph.defs#modlist', + ) + if (curateLists.length > 0) { + _items = _items.concat( + { + type: 'section-header', + title: _(msg`User lists`), + _reactKey: 'curatelist', + }, + curateLists.map(list => ({type: 'list', list})), + ) + } + if (starterPacks.length > 0) { + _items = _items.concat( + { + type: 'section-header', + title: _(msg`Starter packs`), + _reactKey: 'referencelist', + }, + starterPacks.map(list => ({type: 'list', list})), + ) + } + if (modLists.length > 0) { + _items = _items.concat( + { + type: 'section-header', + title: _(msg`Moderation lists`), + _reactKey: 'modlist', + }, + modLists.map(list => ({type: 'list', list})), + ) + } + } + return _items + }, [isError, isEmpty, isPending, moderationOpts, data, _]) + + const renderItem = useCallback( + ({item}: {item: ListItem}) => { + switch (item.type) { + case 'empty': { + return ( + + + + + + You have no lists. + + + ) + } + case 'error': + return + case 'loading': + return ( + + + + ) + case 'section-header': + return ( + + + {item.title} + + + ) + case 'list': + return ( + + ) + } + return null + }, + [t, error, memberships, onChange, profile, moderationOpts], + ) + + const renderCloseButton = useCallback( + () => ( + + ), + [control, _], + ) + + return ( + setHeaderHeight(e.nativeEvent.layout.height)} + renderRight={renderCloseButton}> + + + Add{' '} + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + )}{' '} + to lists + + + + } + /> + ) +} + +function keyExtractor(item: ListItem) { + return item.type === 'list' ? item.list.uri : item._reactKey +} + +function ListRow({ + profile, + list, + memberships, + onChange, + moderationOpts, +}: { + profile: bsky.profile.AnyProfileView + list: AppBskyGraphDefs.ListView + memberships: ListMembersip[] | undefined + onChange?: ( + type: 'add' | 'remove', + list: AppBskyGraphDefs.ListView, + ) => void | undefined + moderationOpts?: ModerationOpts +}) { + const t = useTheme() + const {_} = useLingui() + const membership = useMemo( + () => getMembership(memberships, list.uri, profile.did), + [memberships, list.uri, profile.did], + ) + const {mutate: listMembershipAdd, isPending: isAddingPending} = + useListMembershipAddMutation({ + onSuccess: () => { + Toast.show(_(msg`Added to list`)) + onChange?.('add', list) + }, + onError: e => Toast.show(cleanError(e), 'xmark'), + }) + const {mutate: listMembershipRemove, isPending: isRemovingPending} = + useListMembershipRemoveMutation({ + onSuccess: () => { + Toast.show(_(msg`Removed from list`)) + onChange?.('remove', list) + }, + onError: e => Toast.show(cleanError(e), 'xmark'), + }) + const isMutating = isAddingPending || isRemovingPending + + const onToggleMembership = useCallback(() => { + if (typeof membership === 'undefined') { + return + } + if (membership === false) { + listMembershipAdd({ + listUri: list.uri, + actorDid: profile.did, + }) + } else { + listMembershipRemove({ + listUri: list.uri, + actorDid: profile.did, + membershipUri: membership, + }) + } + }, [list, profile, membership, listMembershipAdd, listMembershipRemove]) + + if (!moderationOpts) return null + + return ( + + + + + + {sanitizeDisplayName(list.name)} + + + {list.purpose === 'app.bsky.graph.defs#curatelist' && ( + User list + )} + {list.purpose === 'app.bsky.graph.defs#modlist' && ( + Moderation list + )} + + + {membership !== undefined && ( + + )} + + + ) +} diff --git a/src/state/queries/my-lists.ts b/src/state/queries/my-lists.ts index 0f8721c61a..aeb9cf4568 100644 --- a/src/state/queries/my-lists.ts +++ b/src/state/queries/my-lists.ts @@ -1,5 +1,5 @@ -import {AppBskyGraphDefs} from '@atproto/api' -import {QueryClient, useQuery} from '@tanstack/react-query' +import {type AppBskyGraphDefs} from '@atproto/api' +import {type QueryClient, useQuery} from '@tanstack/react-query' import {accumulate} from '#/lib/async/accumulate' import {STALE} from '#/state/queries' diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 879bf22f98..bdcc8cdc39 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -14,7 +14,6 @@ import {toShareUrl} from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import {type Shadow} from '#/state/cache/types' -import {useModalControls} from '#/state/modals' import { RQKEY as profileQueryKey, useProfileBlockMutationQueue, @@ -27,6 +26,7 @@ import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' import {Button, ButtonIcon} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' +import {UserAddRemoveListsDialog} from '#/components/dialogs/lists/UserAddRemoveListsDialog' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' @@ -65,8 +65,8 @@ let ProfileMenu = ({ }): React.ReactNode => { const {_} = useLingui() const {currentAccount, hasSession} = useSession() - const {openModal} = useModalControls() const reportDialogControl = useReportDialogControl() + const addToListsDialogControl = useDialogControl() const queryClient = useQueryClient() const navigation = useNavigation() const isSelf = currentAccount?.did === profile.did @@ -106,17 +106,6 @@ let ProfileMenu = ({ shareUrl(toShareUrl(makeProfileLink(profile))) }, [profile]) - const onPressAddRemoveLists = React.useCallback(() => { - openModal({ - name: 'user-add-remove-lists', - subject: profile.did, - handle: profile.handle, - displayName: profile.displayName || profile.handle, - onAdd: invalidateProfileQuery, - onRemove: invalidateProfileQuery, - }) - }, [profile, openModal, invalidateProfileQuery]) - const onPressMuteAccount = React.useCallback(async () => { if (profile.viewer?.muted) { try { @@ -303,7 +292,7 @@ let ProfileMenu = ({ + onPress={addToListsDialogControl.open}> Add to lists @@ -448,6 +437,12 @@ let ProfileMenu = ({ }} /> + +