Replace Add User to Lists Modal with modern Dialog (#9677)

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2026-06-16 20:41:05 +03:00
committed by GitHub
parent b8fdce6478
commit bb6847f396
13 changed files with 635 additions and 594 deletions
+4 -4
View File
@@ -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"
-22
View File
@@ -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": {
@@ -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({
<UserResult
profile={item.profile}
onChange={onChange}
memberships={memberships}
listMembers={listMembers}
list={list}
moderationOpts={moderationOpts}
/>
)
},
[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({
@@ -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 (
<Dialog.Outer
control={control}
testID="userAddRemoveListsDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<ListsContent profile={profile} onAdd={onAdd} onRemove={onRemove} />
</Dialog.Outer>
)
}
function Empty() {
const t = useTheme()
return (
<View
style={[
a.gap_2xl,
platform({web: {paddingTop: 100}, native: {paddingTop: 64}}),
]}>
<View style={[a.gap_xs, a.align_center]}>
<ListIcon
size="xl"
style={{color: t.atoms.border_contrast_medium.borderColor}}
/>
<Text style={[a.text_center]}>
<Trans>You have no lists.</Trans>
</Text>
</View>
</View>
)
}
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 ? (
<ListItem
listWithMembership={item}
profile={profile}
onAdd={onAdd}
onRemove={onRemove}
/>
) : null
const onClose = () => {
control.close()
}
const listHeader = (
<View
style={[
a.justify_between,
a.align_center,
a.flex_row,
a.pb_lg,
t.atoms.bg,
gtMobile ? a.pt_2xl : a.pt_xl,
]}>
<Text style={[a.text_lg, a.font_semi_bold]}>
{profile ? (
<Trans>
Update {createSanitizedDisplayName(profile, true)} in Lists
</Trans>
) : (
<Trans>Update in Lists</Trans>
)}
</Text>
<Button
testID="doneBtn"
label={l`Close`}
onPress={onClose}
variant="ghost"
color="secondary"
size="small"
shape="round"
style={{margin: -8}}>
<ButtonIcon icon={XIcon} />
</Button>
</View>
)
return (
<Dialog.InnerFlatList
data={isLoading ? [{}] : listItems}
renderItem={
isLoading
? () => (
<View style={[a.align_center, a.py_2xl]}>
<Loader size="xl" />
</View>
)
: renderItem
}
keyExtractor={
isLoading
? () => 'lists_dialog_loader'
: (item: ListWithMembership) => item.list.uri
}
onEndReached={() => void onEndReached()}
onEndReachedThreshold={0.1}
stickyHeaderIndices={web([0])}
ListHeaderComponent={listHeader}
ListFooterComponent={
isFetchingNextPage ? (
<View style={[a.align_center, a.py_lg]}>
<Loader size="lg" />
</View>
) : null
}
ListEmptyComponent={!isLoading && data ? <Empty /> : 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 (
<View
testID={`toggleBtn-${list.name}`}
style={[a.flex_row, a.align_center, a.py_md, a.gap_md]}>
<UserAvatar size={40} avatar={list.avatar} type="list" />
<View style={[a.flex_1]}>
<Text
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
numberOfLines={1}>
{sanitizeDisplayName(list.name)}
</Text>
<Text
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>User list by you</Trans>
) : (
<Trans>
User list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
{list.purpose === 'app.bsky.graph.defs#modlist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>Moderation list by you</Trans>
) : (
<Trans>
Moderation list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
</Text>
</View>
<Button
testID={`user-${profile.handle}-addBtn`}
label={isMember ? l`Remove` : l`Add`}
onPress={handleToggleMembership}
disabled={isPending}
size="tiny"
color={isMember ? 'secondary' : 'primary_subtle'}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText>
{isMember ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText>
</Button>
</View>
)
}
@@ -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'
+1 -13
View File
@@ -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
+16 -134
View File
@@ -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<ListMembersip[]>({
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<ListMembersip[]>(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<AppBskyGraphGetStarterPacksWithMembership.OutputSchema>
@@ -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<ListMembersip[]>(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<AppBskyGraphGetStarterPacksWithMembership.OutputSchema>
>(STARTER_PACKS_WITH_MEMBERSHIPS_RKEY(variables.actorDid), old => {
+122
View File
@@ -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<AppBskyGraphGetListsWithMembership.OutputSchema>,
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<AppBskyGraphGetListsWithMembership.OutputSchema>
>(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<AppBskyGraphGetListsWithMembership.OutputSchema>
>(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
}),
})),
}
})
}
+70 -35
View File
@@ -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,37 +144,36 @@ 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) {
({item}: {item: Item}) => {
switch (item.kind) {
case 'empty': {
return renderEmptyState()
} else if (item === ERROR_ITEM) {
}
case 'error': {
return (
<ErrorMessage
message={cleanError(error)}
onPressTryAgain={onPressTryAgain}
/>
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
}
case 'load_more_error': {
return (
<LoadMoreRetryBtn
label={_(
@@ -161,16 +182,22 @@ export function ListMembers({
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING_ITEM) {
}
case 'loading': {
return <ProfileCardFeedLoadingPlaceholder />
}
const profile = (item as AppBskyGraphDefs.ListItemView).subject
case 'list_item': {
const profile = item.listItem.subject
if (!moderationOpts) return null
return (
<View
style={[a.py_md, a.px_xl, a.border_t, t.atoms.border_contrast_low]}>
style={[
a.py_md,
a.px_xl,
a.border_t,
t.atoms.border_contrast_low,
]}>
<ProfileCard.Link profile={profile}>
<ProfileCard.Outer>
<ProfileCard.Header>
@@ -188,7 +215,6 @@ export function ListMembers({
label={_(msg({message: 'Edit', context: 'action'}))}
onPress={e => onPressEditMembership(e, profile)}
size="small"
variant="solid"
color="secondary">
<ButtonText>
<Trans context="action">Edit</Trans>
@@ -207,6 +233,8 @@ export function ListMembers({
</ProfileCard.Link>
</View>
)
}
}
},
[
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}
/>
<UserAddRemoveListsDialog
control={editListsDialogControl}
profile={selectedProfile}
/>
</View>
)
}
+1 -5
View File
@@ -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 = <UserAddRemoveListsModal.Component {...activeModal} />
} else {
{
return null
}
+2 -5
View File
@@ -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 = <UserAddRemoveLists.Component {...modal} />
} else {
{
return null
}
-299
View File
@@ -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 (
<View testID="userAddRemoveListsModal" style={s.hContentRegion}>
<Text style={headerStyles} numberOfLines={1}>
<Trans>
Update{' '}
<Text style={headerStyles} numberOfLines={1}>
{displayName}
</Text>{' '}
in Lists
</Trans>
</Text>
<MyLists
filter="all"
inline
renderItem={(list, index) => (
<ListItem
key={list.uri}
index={index}
list={list}
memberships={memberships}
subject={subject}
handle={handle}
onAdd={onAdd}
onRemove={onRemove}
/>
)}
style={listStyle}
/>
<View style={[styles.btns, pal.border]}>
<Button
testID="doneBtn"
type="default"
onPress={onPressDone}
style={styles.footerBtn}
accessibilityLabel={_(msg({message: `Done`, context: 'action'}))}
accessibilityHint=""
onAccessibilityEscape={onPressDone}
label={_(msg({message: `Done`, context: 'action'}))}
/>
</View>
</View>
)
}
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 (
<View
testID={`toggleBtn-${list.name}`}
style={[
styles.listItem,
pal.border,
index !== 0 && {borderTopWidth: StyleSheet.hairlineWidth},
]}>
<View style={styles.listItemAvi}>
<UserAvatar size={40} avatar={list.avatar} type="list" />
</View>
<View style={styles.listItemContent}>
<Text
type="lg"
style={[{fontWeight: '600'}, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(list.name)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>User list by you</Trans>
) : (
<Trans>
User list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
{list.purpose === 'app.bsky.graph.defs#modlist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>Moderation list by you</Trans>
) : (
<Trans>
Moderation list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
</Text>
</View>
<View>
{isProcessing || typeof membership === 'undefined' ? (
<ActivityIndicator />
) : (
<Button
testID={`user-${handle}-addBtn`}
type="default"
label={membership === false ? _(msg`Add`) : _(msg`Remove`)}
onPress={onToggleMembership}
/>
)}
</View>
</View>
)
}
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',
},
})
+10 -11
View File
@@ -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<NavigationProp>()
@@ -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}
/>
<UserAddRemoveListsDialog
control={addToListsDialogControl}
profile={profile}
onAdd={invalidateProfileQuery}
onRemove={invalidateProfileQuery}
/>
<ReportDialog
control={reportDialogControl}
subject={{