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}
- />
-
-
-
-
- )
-}
-
-function ListItem({
- index,
- list,
- memberships,
- subject,
- handle,
- onAdd,
- onRemove,
-}: {
- index: number
- list: GraphDefs.ListView
- memberships: ListMembersip[] | undefined
- subject: string
- handle: string
- onAdd?: (listUri: string) => void
- onRemove?: (listUri: string) => void
-}) {
- const pal = usePalette('default')
- const {_} = useLingui()
- const {currentAccount} = useSession()
- const [isProcessing, setIsProcessing] = useState(false)
- const membership = useMemo(
- () => getMembership(memberships, list.uri, subject),
- [memberships, list.uri, subject],
- )
- const listMembershipAddMutation = useListMembershipAddMutation()
- const listMembershipRemoveMutation = useListMembershipRemoveMutation()
-
- const onToggleMembership = useCallback(async () => {
- if (typeof membership === 'undefined') {
- return
- }
- setIsProcessing(true)
- try {
- if (membership === false) {
- await listMembershipAddMutation.mutateAsync({
- listUri: list.uri,
- actorDid: subject,
- })
- Toast.show(_(msg`Added to list`))
- onAdd?.(list.uri)
- } else {
- await listMembershipRemoveMutation.mutateAsync({
- listUri: list.uri,
- actorDid: subject,
- membershipUri: membership,
- })
- Toast.show(_(msg`Removed from list`))
- onRemove?.(list.uri)
- }
- } catch (e) {
- Toast.show(cleanError(e), 'xmark')
- } finally {
- setIsProcessing(false)
- }
- }, [
- _,
- list,
- subject,
- membership,
- setIsProcessing,
- onAdd,
- onRemove,
- listMembershipAddMutation,
- listMembershipRemoveMutation,
- ])
-
- 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, '@')}
-
- ))}
-
-
-
- {isProcessing || typeof membership === 'undefined' ? (
-
- ) : (
-
- )}
-
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- paddingHorizontal: IS_WEB ? 0 : 16,
- },
- btns: {
- position: 'relative',
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: 10,
- paddingTop: 10,
- paddingBottom: IS_ANDROID ? 10 : 0,
- borderTopWidth: StyleSheet.hairlineWidth,
- },
- footerBtn: {
- paddingHorizontal: 24,
- paddingVertical: 12,
- },
-
- listItem: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 14,
- paddingVertical: 10,
- },
- listItemAvi: {
- width: 54,
- paddingLeft: 4,
- paddingTop: 8,
- paddingBottom: 10,
- },
- listItemContent: {
- flex: 1,
- paddingRight: 10,
- paddingTop: 10,
- paddingBottom: 10,
- },
- checkbox: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- borderWidth: 1,
- width: 24,
- height: 24,
- borderRadius: 6,
- marginRight: 8,
- },
- loadingContainer: {
- position: 'absolute',
- top: 10,
- right: 0,
- bottom: 0,
- justifyContent: 'center',
- },
-})
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
index 9e42db2d64..51e89f843a 100644
--- a/src/view/com/profile/ProfileMenu.tsx
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -9,7 +9,6 @@ import {type NavigationProp} from '#/lib/routes/types'
import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {type Shadow} from '#/state/cache/types'
-import {useModalControls} from '#/state/modals'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {
RQKEY as profileQueryKey,
@@ -22,6 +21,7 @@ import {EventStopper} from '#/view/com/util/EventStopper'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
+import {UserAddRemoveListsDialog} from '#/components/dialogs/lists/UserAddRemoveListsDialog'
import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
@@ -72,7 +72,6 @@ let ProfileMenu = ({
const ax = useAnalytics()
const {t: l} = useLingui()
const {currentAccount, hasSession} = useSession()
- const {openModal} = useModalControls()
const reportDialogControl = useReportDialogControl()
const queryClient = useQueryClient()
const navigation = useNavigation()
@@ -105,6 +104,7 @@ let ProfileMenu = ({
const goLiveDialogControl = useDialogControl()
const goLiveDisabledDialogControl = useDialogControl()
const addToStarterPacksDialogControl = useDialogControl()
+ const addToListsDialogControl = useDialogControl()
const showLoggedOutWarning = useMemo(() => {
return (
@@ -129,15 +129,8 @@ let ProfileMenu = ({
}, [profile])
const onPressAddRemoveLists = 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])
+ addToListsDialogControl.open()
+ }, [addToListsDialogControl])
const onPressMuteAccount = useCallback(async () => {
if (profile.viewer?.muted) {
@@ -530,6 +523,12 @@ let ProfileMenu = ({
control={addToStarterPacksDialogControl}
targetDid={profile.did}
/>
+