Update Muted and Blocked accounts screens (react-query refactor) (#1892)

* Add my-blocked-accounts and my-muted-accounts queries

* Update ProfileCard to use the profile shadow cache and useModerationOpts

* Update blocked accounts and muted accounts screens
zio/stable
Paul Frazee 2023-11-13 17:30:56 -08:00 committed by GitHub
parent 0501c2be77
commit a81c4b68fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 212 additions and 289 deletions

View File

@ -1,107 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyGraphGetBlocks as GetBlocks,
AppBskyActorDefs as ActorDefs,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export class BlockedAccountsModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
hasMore = true
loadMoreCursor?: string
// data
blocks: ActorDefs.ProfileView[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.blocks.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
const res = await this.rootStore.agent.app.bsky.graph.getBlocks({
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch user followers', {error: err})
}
}
// helper functions
// =
_replaceAll(res: GetBlocks.Response) {
this.blocks = []
this._appendAll(res)
}
_appendAll(res: GetBlocks.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.blocks = this.blocks.concat(res.data.blocks)
}
}

View File

@ -1,107 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyGraphGetMutes as GetMutes,
AppBskyActorDefs as ActorDefs,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {logger} from '#/logger'
const PAGE_SIZE = 30
export class MutedAccountsModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
hasMore = true
loadMoreCursor?: string
// data
mutes: ActorDefs.ProfileView[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.mutes.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async refresh() {
return this.loadMore(true)
}
loadMore = bundleAsync(async (replace: boolean = false) => {
if (!replace && !this.hasMore) {
return
}
this._xLoading(replace)
try {
const res = await this.rootStore.agent.app.bsky.graph.getMutes({
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
if (replace) {
this._replaceAll(res)
} else {
this._appendAll(res)
}
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
logger.error('Failed to fetch user followers', {error: err})
}
}
// helper functions
// =
_replaceAll(res: GetMutes.Response) {
this.mutes = []
this._appendAll(res)
}
_appendAll(res: GetMutes.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.mutes = this.mutes.concat(res.data.mutes)
}
}

View File

@ -0,0 +1,28 @@
import {AppBskyGraphGetBlocks} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {useSession} from '../session'
export const RQKEY = () => ['my-blocked-accounts']
type RQPageParam = string | undefined
export function useMyBlockedAccountsQuery() {
const {agent} = useSession()
return useInfiniteQuery<
AppBskyGraphGetBlocks.OutputSchema,
Error,
InfiniteData<AppBskyGraphGetBlocks.OutputSchema>,
QueryKey,
RQPageParam
>({
queryKey: RQKEY(),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await agent.app.bsky.graph.getBlocks({
limit: 30,
cursor: pageParam,
})
return res.data
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
})
}

View File

@ -0,0 +1,28 @@
import {AppBskyGraphGetMutes} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {useSession} from '../session'
export const RQKEY = () => ['my-muted-accounts']
type RQPageParam = string | undefined
export function useMyMutedAccountsQuery() {
const {agent} = useSession()
return useInfiniteQuery<
AppBskyGraphGetMutes.OutputSchema,
Error,
InfiniteData<AppBskyGraphGetMutes.OutputSchema>,
QueryKey,
RQPageParam
>({
queryKey: RQKEY(),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await agent.app.bsky.graph.getMutes({
limit: 30,
cursor: pageParam,
})
return res.data
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
})
}

View File

@ -11,6 +11,8 @@ import {useSession} from '../session'
import {updateProfileShadow} from '../cache/profile-shadow'
import {uploadBlob} from '#/lib/api'
import {until} from '#/lib/async/until'
import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
export const RQKEY = (did: string) => ['profile', did]
@ -147,6 +149,7 @@ export function useProfileUnfollowMutation() {
export function useProfileMuteMutation() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => {
await agent.mute(did)
@ -157,6 +160,9 @@ export function useProfileMuteMutation() {
muted: true,
})
},
onSuccess() {
queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
},
onError(error, variables) {
// revert the optimistic update
updateProfileShadow(variables.did, {
@ -189,6 +195,7 @@ export function useProfileUnmuteMutation() {
export function useProfileBlockMutation() {
const {agent, currentAccount} = useSession()
const queryClient = useQueryClient()
return useMutation<{uri: string; cid: string}, Error, {did: string}>({
mutationFn: async ({did}) => {
if (!currentAccount) {
@ -210,6 +217,7 @@ export function useProfileBlockMutation() {
updateProfileShadow(variables.did, {
blockingUri: data.uri,
})
queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()})
},
onError(error, variables) {
// revert the optimistic update

View File

@ -64,6 +64,7 @@ export function ListMembers({
const {
data,
dataUpdatedAt,
isFetching,
isFetched,
isError,
@ -184,6 +185,7 @@ export function ListMembers({
(item as AppBskyGraphDefs.ListItemView).subject.handle
}`}
profile={(item as AppBskyGraphDefs.ListItemView).subject}
dataUpdatedAt={dataUpdatedAt}
renderButton={renderMemberButton}
style={{paddingHorizontal: isMobile ? 8 : 14, paddingVertical: 4}}
/>
@ -196,6 +198,7 @@ export function ListMembers({
onPressTryAgain,
onPressRetryLoadMore,
isMobile,
dataUpdatedAt,
],
)

View File

@ -21,10 +21,13 @@ import {
getProfileModerationCauses,
getModerationCauseKey,
} from 'lib/moderation'
import {useModerationOpts} from '#/state/queries/preferences'
import {useProfileShadow} from '#/state/cache/profile-shadow'
export const ProfileCard = observer(function ProfileCardImpl({
export function ProfileCard({
testID,
profile,
profile: profileUnshadowed,
dataUpdatedAt,
noBg,
noBorder,
followers,
@ -33,16 +36,20 @@ export const ProfileCard = observer(function ProfileCardImpl({
}: {
testID?: string
profile: AppBskyActorDefs.ProfileViewBasic
dataUpdatedAt: number
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode
style?: StyleProp<ViewStyle>
}) {
const store = useStores()
const pal = usePalette('default')
const moderation = moderateProfile(profile, store.preferences.moderationOpts)
const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt)
const moderationOpts = useModerationOpts()
if (!moderationOpts) {
return null
}
const moderation = moderateProfile(profile, moderationOpts)
return (
<Link
@ -100,7 +107,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
<FollowersList followers={followers} />
</Link>
)
})
}
function ProfileCardPills({
followedBy,

View File

@ -1,4 +1,4 @@
import React, {useMemo} from 'react'
import React from 'react'
import {
ActivityIndicator,
FlatList,
@ -8,56 +8,78 @@ import {
} from 'react-native'
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
import {Text} from '../com/util/text/Text'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {observer} from 'mobx-react-lite'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from 'lib/routes/types'
import {BlockedAccountsModel} from 'state/models/lists/blocked-accounts'
import {useAnalytics} from 'lib/analytics/analytics'
import {useFocusEffect} from '@react-navigation/native'
import {ViewHeader} from '../com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views'
import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {ProfileCard} from 'view/com/profile/ProfileCard'
import {logger} from '#/logger'
import {useSetMinimalShellMode} from '#/state/shell'
import {useMyBlockedAccountsQuery} from '#/state/queries/my-blocked-accounts'
import {cleanError} from '#/lib/strings/errors'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'ModerationBlockedAccounts'
>
export const ModerationBlockedAccounts = withAuthRequired(
observer(function ModerationBlockedAccountsImpl({}: Props) {
function ModerationBlockedAccountsImpl({}: Props) {
const pal = usePalette('default')
const store = useStores()
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
const blockedAccounts = useMemo(
() => new BlockedAccountsModel(store),
[store],
)
const [isPTRing, setIsPTRing] = React.useState(false)
const {
data,
dataUpdatedAt,
isFetching,
isError,
error,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useMyBlockedAccountsQuery()
const isEmpty = !isFetching && !data?.pages[0]?.blocks.length
const profiles = React.useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.blocks)
}
return []
}, [data])
useFocusEffect(
React.useCallback(() => {
screen('BlockedAccounts')
setMinimalShellMode(false)
blockedAccounts.refresh()
}, [screen, setMinimalShellMode, blockedAccounts]),
}, [screen, setMinimalShellMode]),
)
const onRefresh = React.useCallback(() => {
blockedAccounts.refresh()
}, [blockedAccounts])
const onEndReached = React.useCallback(() => {
blockedAccounts
.loadMore()
.catch(err =>
logger.error('Failed to load more blocked accounts', {error: err}),
)
}, [blockedAccounts])
const onRefresh = React.useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh my muted accounts', {error: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more of my muted accounts', {error: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage])
const renderItem = ({
item,
@ -70,6 +92,7 @@ export const ModerationBlockedAccounts = withAuthRequired(
testID={`blockedAccount-${index}`}
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
)
return (
@ -93,24 +116,32 @@ export const ModerationBlockedAccounts = withAuthRequired(
otherwise interact with you. You will not see their content and they
will be prevented from seeing yours.
</Text>
{!blockedAccounts.hasContent ? (
{isEmpty ? (
<View style={[pal.border, !isTabletOrDesktop && styles.flex1]}>
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
You have not blocked any accounts yet. To block an account, go
to their profile and selected "Block account" from the menu on
their account.
</Text>
</View>
{isError ? (
<ErrorScreen
title="Oops!"
message={cleanError(error)}
onPressTryAgain={refetch}
/>
) : (
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
You have not blocked any accounts yet. To block an account, go
to their profile and selected "Block account" from the menu on
their account.
</Text>
</View>
)}
</View>
) : (
<FlatList
style={[!isTabletOrDesktop && styles.flex1]}
data={blockedAccounts.blocks}
data={profiles}
keyExtractor={(item: ActorDefs.ProfileView) => item.did}
refreshControl={
<RefreshControl
refreshing={blockedAccounts.isRefreshing}
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
@ -120,20 +151,19 @@ export const ModerationBlockedAccounts = withAuthRequired(
renderItem={renderItem}
initialNumToRender={15}
// FIXME(dan)
// eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => (
<View style={styles.footer}>
{blockedAccounts.isLoading && <ActivityIndicator />}
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
</View>
)}
extraData={blockedAccounts.isLoading}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
</CenteredView>
)
}),
},
)
const styles = StyleSheet.create({

View File

@ -1,4 +1,4 @@
import React, {useMemo} from 'react'
import React from 'react'
import {
ActivityIndicator,
FlatList,
@ -8,53 +8,78 @@ import {
} from 'react-native'
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
import {Text} from '../com/util/text/Text'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {observer} from 'mobx-react-lite'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from 'lib/routes/types'
import {MutedAccountsModel} from 'state/models/lists/muted-accounts'
import {useAnalytics} from 'lib/analytics/analytics'
import {useFocusEffect} from '@react-navigation/native'
import {ViewHeader} from '../com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views'
import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {ProfileCard} from 'view/com/profile/ProfileCard'
import {logger} from '#/logger'
import {useSetMinimalShellMode} from '#/state/shell'
import {useMyMutedAccountsQuery} from '#/state/queries/my-muted-accounts'
import {cleanError} from '#/lib/strings/errors'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'ModerationMutedAccounts'
>
export const ModerationMutedAccounts = withAuthRequired(
observer(function ModerationMutedAccountsImpl({}: Props) {
function ModerationMutedAccountsImpl({}: Props) {
const pal = usePalette('default')
const store = useStores()
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop} = useWebMediaQueries()
const {screen} = useAnalytics()
const mutedAccounts = useMemo(() => new MutedAccountsModel(store), [store])
const [isPTRing, setIsPTRing] = React.useState(false)
const {
data,
dataUpdatedAt,
isFetching,
isError,
error,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useMyMutedAccountsQuery()
const isEmpty = !isFetching && !data?.pages[0]?.mutes.length
const profiles = React.useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.mutes)
}
return []
}, [data])
useFocusEffect(
React.useCallback(() => {
screen('MutedAccounts')
setMinimalShellMode(false)
mutedAccounts.refresh()
}, [screen, setMinimalShellMode, mutedAccounts]),
}, [screen, setMinimalShellMode]),
)
const onRefresh = React.useCallback(() => {
mutedAccounts.refresh()
}, [mutedAccounts])
const onEndReached = React.useCallback(() => {
mutedAccounts
.loadMore()
.catch(err =>
logger.error('Failed to load more muted accounts', {error: err}),
)
}, [mutedAccounts])
const onRefresh = React.useCallback(async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh my muted accounts', {error: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more of my muted accounts', {error: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage])
const renderItem = ({
item,
@ -67,6 +92,7 @@ export const ModerationMutedAccounts = withAuthRequired(
testID={`mutedAccount-${index}`}
key={item.did}
profile={item}
dataUpdatedAt={dataUpdatedAt}
/>
)
return (
@ -89,24 +115,32 @@ export const ModerationMutedAccounts = withAuthRequired(
Muted accounts have their posts removed from your feed and from your
notifications. Mutes are completely private.
</Text>
{!mutedAccounts.hasContent ? (
{isEmpty ? (
<View style={[pal.border, !isTabletOrDesktop && styles.flex1]}>
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
You have not muted any accounts yet. To mute an account, go to
their profile and selected "Mute account" from the menu on their
account.
</Text>
</View>
{isError ? (
<ErrorScreen
title="Oops!"
message={cleanError(error)}
onPressTryAgain={refetch}
/>
) : (
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
You have not muted any accounts yet. To mute an account, go to
their profile and selected "Mute account" from the menu on
their account.
</Text>
</View>
)}
</View>
) : (
<FlatList
style={[!isTabletOrDesktop && styles.flex1]}
data={mutedAccounts.mutes}
data={profiles}
keyExtractor={item => item.did}
refreshControl={
<RefreshControl
refreshing={mutedAccounts.isRefreshing}
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
@ -116,20 +150,19 @@ export const ModerationMutedAccounts = withAuthRequired(
renderItem={renderItem}
initialNumToRender={15}
// FIXME(dan)
// eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => (
<View style={styles.footer}>
{mutedAccounts.isLoading && <ActivityIndicator />}
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
</View>
)}
extraData={mutedAccounts.isLoading}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
</CenteredView>
)
}),
},
)
const styles = StyleSheet.create({