From bb6847f39626dacd611826907e88813eb7da21a4 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 16 Jun 2026 20:41:05 +0300 Subject: [PATCH] Replace Add User to Lists Modal with modern Dialog (#9677) Co-authored-by: Claude Haiku 4.5 Co-authored-by: Eric Bailey --- __e2e__/flows/curate-lists.yml | 8 +- eslint-suppressions.json | 22 -- .../lists/ListAddRemoveUsersDialog.tsx | 32 +- .../lists/UserAddRemoveListsDialog.tsx | 331 ++++++++++++++++++ .../Messages/components/RequestButtons.tsx | 2 +- src/state/modals/index.tsx | 14 +- src/state/queries/list-memberships.ts | 150 +------- src/state/queries/lists-with-membership.ts | 122 +++++++ src/view/com/lists/ListMembers.tsx | 215 +++++++----- src/view/com/modals/Modal.tsx | 6 +- src/view/com/modals/Modal.web.tsx | 7 +- src/view/com/modals/UserAddRemoveLists.tsx | 299 ---------------- src/view/com/profile/ProfileMenu.tsx | 21 +- 13 files changed, 635 insertions(+), 594 deletions(-) create mode 100644 src/components/dialogs/lists/UserAddRemoveListsDialog.tsx create mode 100644 src/state/queries/lists-with-membership.ts delete mode 100644 src/view/com/modals/UserAddRemoveLists.tsx diff --git a/__e2e__/flows/curate-lists.yml b/__e2e__/flows/curate-lists.yml index 906199e979..cc97b0fd72 100644 --- a/__e2e__/flows/curate-lists.yml +++ b/__e2e__/flows/curate-lists.yml @@ -145,21 +145,21 @@ appId: xyz.blueskyweb.app id: "profileHeaderDropdownBtn" - tapOn: "Add to Lists" - assertVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" - tapOn: id: "user-bob.test-addBtn" - tapOn: id: "doneBtn" - assertNotVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" - tapOn: id: "profileHeaderDropdownBtn" - tapOn: "Add to Lists" - assertVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" - tapOn: id: "user-bob.test-addBtn" - tapOn: id: "doneBtn" - assertNotVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a3cbe60000..1e8edd043e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1836,11 +1836,6 @@ "count": 1 } }, - "src/state/queries/list-memberships.ts": { - "@typescript-eslint/no-floating-promises": { - "count": 2 - } - }, "src/state/queries/list.ts": { "@typescript-eslint/no-floating-promises": { "count": 7 @@ -2163,20 +2158,6 @@ "count": 2 } }, - "src/view/com/lists/ListMembers.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - }, - "@typescript-eslint/no-floating-promises": { - "count": 1 - }, - "@typescript-eslint/no-misused-promises": { - "count": 2 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 2 - } - }, "src/view/com/lists/MyLists.tsx": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -2208,9 +2189,6 @@ }, "@typescript-eslint/require-await": { "count": 1 - }, - "react-hooks/refs": { - "count": 1 } }, "src/view/com/notifications/NotificationFeed.tsx": { diff --git a/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx index da96565655..8129e9bdb4 100644 --- a/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx +++ b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx @@ -7,10 +7,8 @@ import {Trans} from '@lingui/react/macro' import {cleanError} from '#/lib/strings/errors' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useAllListMembersQuery} from '#/state/queries/list-members' import { - getMembership, - type ListMembersip, - useDangerousListMembershipsQuery, useListMembershipAddMutation, useListMembershipRemoveMutation, } from '#/state/queries/list-memberships' @@ -61,7 +59,7 @@ function DialogInner({ }) { const {_} = useLingui() const moderationOpts = useModerationOpts() - const {data: memberships} = useDangerousListMembershipsQuery() + const {data: listMembers} = useAllListMembersQuery(list.uri) const renderProfileCard = useCallback( (item: ProfileItem) => { @@ -69,13 +67,13 @@ function DialogInner({ ) }, - [onChange, memberships, list, moderationOpts], + [onChange, listMembers, list, moderationOpts], ) return ( @@ -86,16 +84,30 @@ function DialogInner({ ) } +/** + * Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record) + */ +function getMembership( + listMembers: AppBskyGraphDefs.ListItemView[] | undefined, + actorDid: string, +): string | false | undefined { + if (!listMembers) { + return undefined + } + const member = listMembers.find(item => item.subject.did === actorDid) + return member ? member.uri : false +} + function UserResult({ profile, list, - memberships, + listMembers, onChange, moderationOpts, }: { profile: bsky.profile.AnyProfileView list: AppBskyGraphDefs.ListView - memberships: ListMembersip[] | undefined + listMembers: AppBskyGraphDefs.ListItemView[] | undefined onChange?: ( type: 'add' | 'remove', profile: bsky.profile.AnyProfileView, @@ -104,8 +116,8 @@ function UserResult({ }) { const {_} = useLingui() const membership = useMemo( - () => getMembership(memberships, list.uri, profile.did), - [memberships, list.uri, profile.did], + () => getMembership(listMembers, profile.did), + [listMembers, profile.did], ) const {mutate: listMembershipAdd, isPending: isAddingPending} = useListMembershipAddMutation({ diff --git a/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx b/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx new file mode 100644 index 0000000000..de8f453d5a --- /dev/null +++ b/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx @@ -0,0 +1,331 @@ +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' +import {useQueryClient} from '@tanstack/react-query' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {isNetworkError} from '#/lib/strings/errors' +import {sanitizeHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +import { + useListMembershipAddMutation, + useListMembershipRemoveMutation, +} from '#/state/queries/list-memberships' +import { + type ListWithMembership, + removeListMembershipOptimistically, + updateListMembershipOptimistically, + useListsWithMembershipQuery, +} from '#/state/queries/lists-with-membership' +import {useSession} from '#/state/session' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, platform, useBreakpoints, 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 Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export type UserAddRemoveListsDialogProps = { + control: Dialog.DialogControlProps + profile: bsky.profile.AnyProfileView | undefined + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +} + +export function UserAddRemoveListsDialog({ + control, + profile, + onAdd, + onRemove, +}: UserAddRemoveListsDialogProps) { + return ( + + + + + ) +} + +function Empty() { + const t = useTheme() + + return ( + + + + + You have no lists. + + + + ) +} + +function ListsContent({ + profile, + onAdd, + onRemove, +}: { + profile: bsky.profile.AnyProfileView | undefined + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +}) { + const t = useTheme() + const control = Dialog.useDialogContext() + const {t: l} = useLingui() + const {gtMobile} = useBreakpoints() + + const { + data, + isError, + isLoading, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useListsWithMembershipQuery({actor: profile?.did}) + + const listItems = data?.pages.flatMap(page => page.listsWithMembership) || [] + + const onEndReached = async () => { + if (isFetchingNextPage || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + // Error handling is optional since this is just pagination + } + } + + const renderItem = ({item}: {item: ListWithMembership}) => + profile ? ( + + ) : null + + const onClose = () => { + control.close() + } + + const listHeader = ( + + + {profile ? ( + + Update {createSanitizedDisplayName(profile, true)} in Lists + + ) : ( + Update in Lists + )} + + + + ) + + return ( + ( + + + + ) + : renderItem + } + keyExtractor={ + isLoading + ? () => 'lists_dialog_loader' + : (item: ListWithMembership) => item.list.uri + } + onEndReached={() => void onEndReached()} + onEndReachedThreshold={0.1} + stickyHeaderIndices={web([0])} + ListHeaderComponent={listHeader} + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + ListEmptyComponent={!isLoading && data ? : null} + webInnerContentContainerStyle={[a.py_0]} + style={platform({ + web: [a.px_2xl, a.pb_md], + native: [a.px_2xl, a.pt_lg], + })} + /> + ) +} + +function ListItem({ + listWithMembership, + profile, + onAdd, + onRemove, +}: { + listWithMembership: ListWithMembership + profile: bsky.profile.AnyProfileView + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +}) { + const {t: l} = useLingui() + const t = useTheme() + const queryClient = useQueryClient() + const {currentAccount} = useSession() + + const list = listWithMembership.list + const listItem = listWithMembership.listItem + const isMember = !!listItem + + const {mutate: addMembership, isPending: isPendingAdd} = + useListMembershipAddMutation({ + subject: profile, + onSuccess: data => { + Toast.show(l`Added to list`) + onAdd?.(list.uri) + updateListMembershipOptimistically({ + queryClient, + actor: profile.did, + listUri: list.uri, + membershipUri: data.uri, + subject: { + did: profile.did, + handle: profile.handle, + displayName: profile.displayName, + }, + }) + }, + onError: err => { + if (!isNetworkError(err)) { + logger.error('Failed to add to list', {safeMessage: err}) + } + Toast.show(l`Failed to add to list`, {type: 'error'}) + }, + }) + + const {mutate: removeMembership, isPending: isPendingRemove} = + useListMembershipRemoveMutation({ + onSuccess: () => { + Toast.show(l`Removed from list`) + onRemove?.(list.uri) + removeListMembershipOptimistically({ + queryClient, + actor: profile.did, + listUri: list.uri, + }) + }, + onError: err => { + if (!isNetworkError(err)) { + logger.error('Failed to remove from list', {safeMessage: err}) + } + Toast.show(l`Failed to remove from list`, {type: 'error'}) + }, + }) + + const isPending = isPendingAdd || isPendingRemove + + const handleToggleMembership = () => { + if (isPending) return + + if (!isMember) { + addMembership({ + listUri: list.uri, + actorDid: profile.did, + }) + } else { + if (!listItem?.uri) { + logger.error('Cannot remove: missing membership URI') + return + } + removeMembership({ + listUri: list.uri, + actorDid: profile.did, + membershipUri: listItem.uri, + }) + } + } + + return ( + + + + + {sanitizeDisplayName(list.name)} + + + {list.purpose === 'app.bsky.graph.defs#curatelist' && + (list.creator.did === currentAccount?.did ? ( + User list by you + ) : ( + + User list by {sanitizeHandle(list.creator.handle, '@')} + + ))} + {list.purpose === 'app.bsky.graph.defs#modlist' && + (list.creator.did === currentAccount?.did ? ( + Moderation list by you + ) : ( + + Moderation list by {sanitizeHandle(list.creator.handle, '@')} + + ))} + + + + + ) +} diff --git a/src/screens/Messages/components/RequestButtons.tsx b/src/screens/Messages/components/RequestButtons.tsx index dfac74e59f..c5e0e82ece 100644 --- a/src/screens/Messages/components/RequestButtons.tsx +++ b/src/screens/Messages/components/RequestButtons.tsx @@ -1,5 +1,5 @@ import {useCallback} from 'react' -import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' +import {type ChatBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {StackActions, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 3c545362b8..d62ba2a7a0 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -3,15 +3,6 @@ import {createContext, useContext, useEffect, useMemo, useState} from 'react' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useHotkeysContext} from '#/lib/hotkeys' -export interface UserAddRemoveListsModal { - name: 'user-add-remove-lists' - subject: string - handle: string - displayName: string - onAdd?: (listUri: string) => void - onRemove?: (listUri: string) => void -} - export interface ContentLanguagesSettingsModal { name: 'content-languages-settings' } @@ -21,10 +12,7 @@ export interface ContentLanguagesSettingsModal { */ export type Modal = // Curation - | ContentLanguagesSettingsModal - - // Lists - | UserAddRemoveListsModal + ContentLanguagesSettingsModal const ModalContext = createContext<{ isModalActive: boolean diff --git a/src/state/queries/list-memberships.ts b/src/state/queries/list-memberships.ts index dd7fde2482..75adcd5bb8 100644 --- a/src/state/queries/list-memberships.ts +++ b/src/state/queries/list-memberships.ts @@ -1,19 +1,3 @@ -/** - * NOTE - * - * This query is a temporary solution to our lack of server API for - * querying user membership in an API. It is extremely inefficient. - * - * THIS SHOULD ONLY BE USED IN MODALS FOR MODIFYING A USER'S LIST MEMBERSHIP! - * Use the list-members query for rendering a list's members. - * - * It works by fetching *all* of the user's list item records and querying - * or manipulating that cache. For users with large lists, it will fall - * down completely, so be very conservative about how you use it. - * - * -prf - */ - import { type AppBskyActorDefs, type AppBskyGraphGetStarterPacksWithMembership, @@ -22,85 +6,17 @@ import { import { type InfiniteData, useMutation, - useQuery, useQueryClient, } from '@tanstack/react-query' -import {STALE} from '#/state/queries' -import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members' +import { + RQKEY as LIST_MEMBERS_RQKEY, + RQKEY_ALL as LIST_MEMBERS_ALL_RQKEY, +} from '#/state/queries/list-members' import {useAgent, useSession} from '#/state/session' import type * as bsky from '#/types/bsky' import {RQKEY_WITH_MEMBERSHIP as STARTER_PACKS_WITH_MEMBERSHIPS_RKEY} from './actor-starter-packs' -// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records -const SANITY_PAGE_LIMIT = 1000 -const PAGE_SIZE = 100 -// ...which comes 100,000k list members - -const RQKEY_ROOT = 'list-memberships' -export const RQKEY = () => [RQKEY_ROOT] - -export interface ListMembersip { - membershipUri: string - listUri: string - actorDid: string -} - -/** - * This API is dangerous! Read the note above! - */ -export function useDangerousListMembershipsQuery() { - const {currentAccount} = useSession() - const agent = useAgent() - return useQuery({ - staleTime: STALE.MINUTES.FIVE, - queryKey: RQKEY(), - async queryFn() { - if (!currentAccount) { - return [] - } - let cursor - let arr: ListMembersip[] = [] - for (let i = 0; i < SANITY_PAGE_LIMIT; i++) { - const res = await agent.app.bsky.graph.listitem.list({ - repo: currentAccount.did, - limit: PAGE_SIZE, - cursor, - }) - arr = arr.concat( - res.records.map(r => ({ - membershipUri: r.uri, - listUri: r.value.list, - actorDid: r.value.subject, - })), - ) - cursor = res.cursor - if (!cursor) { - break - } - } - return arr - }, - }) -} - -/** - * Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record) - */ -export function getMembership( - memberships: ListMembersip[] | undefined, - list: string, - actor: string, -): string | false | undefined { - if (!memberships) { - return undefined - } - const membership = memberships.find( - m => m.listUri === list && m.actorDid === actor, - ) - return membership ? membership.membershipUri : false -} - export function useListMembershipAddMutation({ subject, onSuccess, @@ -133,43 +49,21 @@ export function useListMembershipAddMutation({ createdAt: new Date().toISOString(), }, ) - // TODO - // we need to wait for appview to update, but there's not an efficient - // query for that, so we use a timeout below - // -prf return res }, onSuccess: (data, variables) => { - // manually update the cache; a refetch is too expensive - let memberships = queryClient.getQueryData(RQKEY()) - if (memberships) { - memberships = memberships - // avoid dups - .filter( - m => - !( - m.actorDid === variables.actorDid && - m.listUri === variables.listUri - ), - ) - .concat([ - { - ...variables, - membershipUri: data.uri, - }, - ]) - queryClient.setQueryData(RQKEY(), memberships) - } // invalidate the members queries (used for rendering the listings) - // use a timeout to wait for the appview (see above) + // use a timeout to wait for the appview setTimeout(() => { - queryClient.invalidateQueries({ + void queryClient.invalidateQueries({ queryKey: LIST_MEMBERS_RQKEY(variables.listUri), }) + void queryClient.invalidateQueries({ + queryKey: LIST_MEMBERS_ALL_RQKEY(variables.listUri), + }) }, 1e3) - // update WITH_MEMBERSHIPS query - + // update WITH_MEMBERSHIPS query for starter packs if (subject) { queryClient.setQueryData< InfiniteData @@ -251,32 +145,20 @@ export function useListMembershipRemoveMutation({ repo: currentAccount.did, rkey: membershipUrip.rkey, }) - // TODO - // we need to wait for appview to update, but there's not an efficient - // query for that, so we use a timeout below - // -prf }, onSuccess: (data, variables) => { - // manually update the cache; a refetch is too expensive - let memberships = queryClient.getQueryData(RQKEY()) - if (memberships) { - memberships = memberships.filter( - m => - !( - m.actorDid === variables.actorDid && - m.listUri === variables.listUri - ), - ) - queryClient.setQueryData(RQKEY(), memberships) - } // invalidate the members queries (used for rendering the listings) - // use a timeout to wait for the appview (see above) + // use a timeout to wait for the appview setTimeout(() => { - queryClient.invalidateQueries({ + void queryClient.invalidateQueries({ queryKey: LIST_MEMBERS_RQKEY(variables.listUri), }) + void queryClient.invalidateQueries({ + queryKey: LIST_MEMBERS_ALL_RQKEY(variables.listUri), + }) }, 1e3) + // update WITH_MEMBERSHIPS query for starter packs queryClient.setQueryData< InfiniteData >(STARTER_PACKS_WITH_MEMBERSHIPS_RKEY(variables.actorDid), old => { diff --git a/src/state/queries/lists-with-membership.ts b/src/state/queries/lists-with-membership.ts new file mode 100644 index 0000000000..3147b8845f --- /dev/null +++ b/src/state/queries/lists-with-membership.ts @@ -0,0 +1,122 @@ +import { + type AppBskyActorDefs, + type AppBskyGraphGetListsWithMembership, +} from '@atproto/api' +import { + type InfiniteData, + type QueryClient, + type QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {createQueryKey} from '#/state/queries/util' +import {useAgent} from '#/state/session' + +export type ListWithMembership = + AppBskyGraphGetListsWithMembership.ListWithMembership + +const listsWithMembershipQueryKeyRoot = 'lists-with-membership' +export const createListsWithMembershipQueryKey = (args: {actor: string}) => + createQueryKey(listsWithMembershipQueryKeyRoot, args) + +export function useListsWithMembershipQuery({ + actor, + enabled = true, +}: { + actor: string | undefined + enabled?: boolean +}) { + const agent = useAgent() + + return useInfiniteQuery< + AppBskyGraphGetListsWithMembership.OutputSchema, + Error, + InfiniteData, + QueryKey, + string | undefined + >({ + queryKey: createListsWithMembershipQueryKey({actor: actor ?? ''}), + queryFn: async ({pageParam}: {pageParam?: string}) => { + const res = await agent.app.bsky.graph.getListsWithMembership({ + actor: actor!, // the enabled flag prevents this from running until actor is set + limit: 50, + cursor: pageParam, + }) + return res.data + }, + enabled: Boolean(actor) && enabled, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export function updateListMembershipOptimistically({ + queryClient, + actor, + listUri, + membershipUri, + subject, +}: { + queryClient: QueryClient + actor: string + listUri: string + membershipUri: string + subject: AppBskyActorDefs.ProfileView +}) { + queryClient.setQueryData< + InfiniteData + >(createListsWithMembershipQueryKey({actor}), old => { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + listsWithMembership: page.listsWithMembership.map(lwm => { + if (lwm.list.uri === listUri) { + return { + ...lwm, + listItem: { + uri: membershipUri, + subject, + }, + } + } + return lwm + }), + })), + } + }) +} + +export function removeListMembershipOptimistically({ + queryClient, + actor, + listUri, +}: { + queryClient: QueryClient + actor: string + listUri: string +}) { + queryClient.setQueryData< + InfiniteData + >(createListsWithMembershipQueryKey({actor}), old => { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + listsWithMembership: page.listsWithMembership.map(lwm => { + if (lwm.list.uri === listUri) { + return { + ...lwm, + listItem: undefined, + } + } + return lwm + }), + })), + } + }) +} diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx index a443c49fd4..04876eefb2 100644 --- a/src/view/com/lists/ListMembers.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -1,4 +1,4 @@ -import {type JSX, useCallback, useMemo, useState} from 'react' +import {useCallback, useMemo, useState} from 'react' import { Dimensions, type GestureResponderEvent, @@ -13,7 +13,6 @@ import {Trans} from '@lingui/react/macro' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {useModalControls} from '#/state/modals' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useListMembersQuery} from '#/state/queries/list-members' import {useSession} from '#/state/session' @@ -23,14 +22,29 @@ import {ProfileCardFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlacehol import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {UserAddRemoveListsDialog} from '#/components/dialogs/lists/UserAddRemoveListsDialog' import {ListFooter} from '#/components/Lists' import * as ProfileCard from '#/components/ProfileCard' import type * as bsky from '#/types/bsky' -const LOADING_ITEM = {_reactKey: '__loading__'} -const EMPTY_ITEM = {_reactKey: '__empty__'} -const ERROR_ITEM = {_reactKey: '__error__'} -const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} +const LOADING_ITEM = {kind: 'loading', _reactKey: '__loading__'} as const +const EMPTY_ITEM = {kind: 'empty', _reactKey: '__empty__'} as const +const ERROR_ITEM = {kind: 'error', _reactKey: '__error__'} as const +const LOAD_MORE_ERROR_ITEM = { + kind: 'load_more_error', + _reactKey: '__load_more_error__', +} as const + +type Item = + | typeof LOADING_ITEM + | typeof EMPTY_ITEM + | typeof ERROR_ITEM + | typeof LOAD_MORE_ERROR_ITEM + | { + kind: 'list_item' + listItem: AppBskyGraphDefs.ListItemView + } export function ListMembers({ list, @@ -49,8 +63,8 @@ export function ListMembers({ scrollElRef?: ListRef onScrolledDownChange: (isScrolledDown: boolean) => void onPressTryAgain?: () => void - renderHeader: () => JSX.Element - renderEmptyState: () => JSX.Element + renderHeader: () => React.ReactElement + renderEmptyState: () => React.ReactElement testID?: string headerOffset?: number desktopFixedHeightOffset?: number @@ -58,9 +72,12 @@ export function ListMembers({ const t = useTheme() const {_} = useLingui() const [isRefreshing, setIsRefreshing] = useState(false) - const {openModal} = useModalControls() const {currentAccount} = useSession() const moderationOpts = useModerationOpts() + const editListsDialogControl = useDialogControl() + const [selectedProfile, setSelectedProfile] = useState< + bsky.profile.AnyProfileView | undefined + >() const { data, @@ -78,23 +95,28 @@ export function ListMembers({ currentAccount && data?.pages[0].list.creator.did === currentAccount.did const items = useMemo(() => { - let items: any[] = [] + const items: Item[] = [] if (isFetched) { if (isEmpty && isError) { - items = items.concat([ERROR_ITEM]) + items.push(ERROR_ITEM) } if (isEmpty) { - items = items.concat([EMPTY_ITEM]) + items.push(EMPTY_ITEM) } else if (data) { for (const page of data.pages) { - items = items.concat(page.items) + items.push( + ...page.items.map(item => ({ + kind: 'list_item' as const, + listItem: item, + })), + ) } } if (!isEmpty && isError) { - items = items.concat([LOAD_MORE_ERROR_ITEM]) + items.push(LOAD_MORE_ERROR_ITEM) } } else if (isFetching) { - items = items.concat([LOADING_ITEM]) + items.push(LOADING_ITEM) } return items }, [isFetched, isEmpty, isError, data, isFetching]) @@ -122,91 +144,97 @@ export function ListMembers({ }, [isFetching, hasNextPage, isError, fetchNextPage]) const onPressRetryLoadMore = useCallback(() => { - fetchNextPage() + void fetchNextPage() }, [fetchNextPage]) const onPressEditMembership = useCallback( (e: GestureResponderEvent, profile: bsky.profile.AnyProfileView) => { e.preventDefault() - openModal({ - name: 'user-add-remove-lists', - subject: profile.did, - displayName: profile.displayName || profile.handle, - handle: profile.handle, - }) + setSelectedProfile(profile) + editListsDialogControl.open() }, - [openModal], + [editListsDialogControl], ) // rendering // = const renderItem = useCallback( - ({item}: {item: any}) => { - if (item === EMPTY_ITEM) { - return renderEmptyState() - } else if (item === ERROR_ITEM) { - return ( - - ) - } else if (item === LOAD_MORE_ERROR_ITEM) { - return ( - - ) - } else if (item === LOADING_ITEM) { - return + ({item}: {item: Item}) => { + switch (item.kind) { + case 'empty': { + return renderEmptyState() + } + case 'error': { + return ( + + ) + } + case 'load_more_error': { + return ( + + ) + } + case 'loading': { + return + } + case 'list_item': { + const profile = item.listItem.subject + if (!moderationOpts) return null + + return ( + + + + + + + {isOwner && ( + + )} + + + + + + + + + ) + } } - - const profile = (item as AppBskyGraphDefs.ListItemView).subject - if (!moderationOpts) return null - - return ( - - - - - - - {isOwner && ( - - )} - - - - - - - - - ) }, [ renderEmptyState, @@ -247,22 +275,29 @@ export function ListMembers({ testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} data={items} - keyExtractor={(item: any) => item.subject?.did || item._reactKey} + keyExtractor={(item: Item) => + item.kind === 'list_item' ? item.listItem.subject.did : item._reactKey + } renderItem={renderItem} ListHeaderComponent={!isEmpty ? renderHeader : undefined} ListFooterComponent={renderFooter} refreshing={isRefreshing} - onRefresh={onRefresh} + onRefresh={() => void onRefresh()} headerOffset={headerOffset} contentContainerStyle={{ minHeight: Dimensions.get('window').height * 1.5, }} onScrolledDownChange={onScrolledDownChange} - onEndReached={onEndReached} + onEndReached={() => void onEndReached()} onEndReachedThreshold={0.6} removeClippedSubviews={true} desktopFixedHeight={desktopFixedHeightOffset || true} /> + + ) } diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index fa95661d27..96b04a1428 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 UserAddRemoveListsModal from './UserAddRemoveLists' const DEFAULT_SNAPPOINTS = ['90%'] const HANDLE_HEIGHT = 24 @@ -40,10 +39,7 @@ export function ModalsContainer() { let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let element - if (activeModal?.name === 'user-add-remove-lists') { - snapPoints = UserAddRemoveListsModal.snapPoints - element = - } else { + { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index dc080bae01..a35c2b391b 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 UserAddRemoveLists from './UserAddRemoveLists' export function ModalsContainer() { const {isModalActive, activeModals} = useModals() @@ -25,7 +24,7 @@ export function ModalsContainer() { ) } -function Modal({modal}: {modal: ModalIface}) { +function Modal({modal: _modal}: {modal: ModalIface}) { const {isModalActive} = useModals() const {closeModal} = useModalControls() const pal = usePalette('default') @@ -44,9 +43,7 @@ function Modal({modal}: {modal: ModalIface}) { } let element - if (modal.name === 'user-add-remove-lists') { - element = - } else { + { return null } diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx deleted file mode 100644 index 1648b0ff22..0000000000 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import {useCallback, useMemo, useState} from 'react' -import { - ActivityIndicator, - StyleSheet, - useWindowDimensions, - View, -} from 'react-native' -import {type AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' - -import {usePalette} from '#/lib/hooks/usePalette' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {cleanError} from '#/lib/strings/errors' -import {sanitizeHandle} from '#/lib/strings/handles' -import {s} from '#/lib/styles' -import {useModalControls} from '#/state/modals' -import { - getMembership, - type ListMembersip, - useDangerousListMembershipsQuery, - useListMembershipAddMutation, - useListMembershipRemoveMutation, -} from '#/state/queries/list-memberships' -import {useSession} from '#/state/session' -import {IS_ANDROID, IS_WEB, IS_WEB_MOBILE} from '#/env' -import {MyLists} from '../lists/MyLists' -import {Button} from '../util/forms/Button' -import {Text} from '../util/text/Text' -import * as Toast from '../util/Toast' -import {UserAvatar} from '../util/UserAvatar' - -export const snapPoints = ['fullscreen'] - -export function Component({ - subject, - handle, - displayName, - onAdd, - onRemove, -}: { - subject: string - handle: string - displayName: string - onAdd?: (listUri: string) => void - onRemove?: (listUri: string) => void -}) { - const {closeModal} = useModalControls() - const pal = usePalette('default') - const {height: screenHeight} = useWindowDimensions() - const {_} = useLingui() - const {data: memberships} = useDangerousListMembershipsQuery() - - const onPressDone = useCallback(() => { - closeModal() - }, [closeModal]) - - const listStyle = useMemo(() => { - if (IS_WEB_MOBILE) { - return [pal.border, {height: screenHeight / 2}] - } else if (IS_WEB) { - return [pal.border, {height: screenHeight / 1.5}] - } - - return [pal.border, {flex: 1, borderTopWidth: StyleSheet.hairlineWidth}] - }, [pal.border, screenHeight]) - - const headerStyles = [ - { - textAlign: 'center', - fontWeight: '600', - fontSize: 20, - marginBottom: 12, - paddingHorizontal: 12, - } as const, - pal.text, - ] - - return ( - - - - Update{' '} - - {displayName} - {' '} - in Lists - - - ( - - )} - style={listStyle} - /> - -