strong types for listmembers

This commit is contained in:
Samuel Newman
2026-06-04 10:16:02 +03:00
parent cc6e133690
commit 9d40d02f95
4 changed files with 114 additions and 401 deletions
-17
View File
@@ -2135,20 +2135,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
@@ -2180,9 +2166,6 @@
},
"@typescript-eslint/require-await": {
"count": 1
},
"react-hooks/refs": {
"count": 1
}
},
"src/view/com/notifications/NotificationFeed.tsx": {
@@ -1,6 +1,5 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
@@ -172,7 +171,7 @@ function ListsContent({
? () => 'lists_dialog_loader'
: (item: ListWithMembership) => item.list.uri
}
onEndReached={onEndReached}
onEndReached={() => void onEndReached()}
onEndReachedThreshold={0.1}
ListHeaderComponent={listHeader}
ListEmptyComponent={<Empty />}
@@ -222,7 +221,7 @@ function ListItem({
did: subjectDid,
handle,
displayName,
} as AppBskyActorDefs.ProfileView,
},
})
},
onError: err => {
+112 -82
View File
@@ -1,4 +1,4 @@
import {type JSX, useCallback, useMemo, useState} from 'react'
import {useCallback, useMemo, useState} from 'react'
import {
Dimensions,
type GestureResponderEvent,
@@ -28,10 +28,23 @@ 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,
@@ -50,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
@@ -62,7 +75,7 @@ export function ListMembers({
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const editListsDialogControl = useDialogControl()
const [selectedProfile, setSelectedProfile] = React.useState<
const [selectedProfile, setSelectedProfile] = useState<
bsky.profile.AnyProfileView | undefined
>()
@@ -82,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])
@@ -126,7 +144,7 @@ export function ListMembers({
}, [isFetching, hasNextPage, isError, fetchNextPage])
const onPressRetryLoadMore = useCallback(() => {
fetchNextPage()
void fetchNextPage()
}, [fetchNextPage])
const onPressEditMembership = useCallback(
@@ -142,71 +160,81 @@ export function ListMembers({
// =
const renderItem = useCallback(
({item}: {item: any}) => {
if (item === EMPTY_ITEM) {
return renderEmptyState()
} else if (item === ERROR_ITEM) {
return (
<ErrorMessage
message={cleanError(error)}
onPressTryAgain={onPressTryAgain}
/>
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label={_(
msg`There was an issue fetching the list. Tap here to try again.`,
)}
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING_ITEM) {
return <ProfileCardFeedLoadingPlaceholder />
({item}: {item: Item}) => {
switch (item.kind) {
case 'empty': {
return renderEmptyState()
}
case 'error': {
return (
<ErrorMessage
message={cleanError(error)}
onPressTryAgain={onPressTryAgain}
/>
)
}
case 'load_more_error': {
return (
<LoadMoreRetryBtn
label={_(
msg`There was an issue fetching the list. Tap here to try again.`,
)}
onPress={onPressRetryLoadMore}
/>
)
}
case 'loading': {
return <ProfileCardFeedLoadingPlaceholder />
}
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,
]}>
<ProfileCard.Link profile={profile}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
{isOwner && (
<Button
testID={`user-${profile.handle}-editBtn`}
label={_(msg({message: 'Edit', context: 'action'}))}
onPress={e => onPressEditMembership(e, profile)}
size="small"
color="secondary">
<ButtonText>
<Trans context="action">Edit</Trans>
</ButtonText>
</Button>
)}
</ProfileCard.Header>
<ProfileCard.Labels
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.Description profile={profile} />
</ProfileCard.Outer>
</ProfileCard.Link>
</View>
)
}
}
const profile = (item as AppBskyGraphDefs.ListItemView).subject
if (!moderationOpts) return null
return (
<View
style={[a.py_md, a.px_xl, a.border_t, t.atoms.border_contrast_low]}>
<ProfileCard.Link profile={profile}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
{isOwner && (
<Button
testID={`user-${profile.handle}-editBtn`}
label={_(msg({message: 'Edit', context: 'action'}))}
onPress={e => onPressEditMembership(e, profile)}
size="small"
variant="solid"
color="secondary">
<ButtonText>
<Trans context="action">Edit</Trans>
</ButtonText>
</Button>
)}
</ProfileCard.Header>
<ProfileCard.Labels
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.Description profile={profile} />
</ProfileCard.Outer>
</ProfileCard.Link>
</View>
)
},
[
renderEmptyState,
@@ -247,18 +275,20 @@ 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}
-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',
},
})