diff --git a/src/state/models/lists/blocked-accounts.ts b/src/state/models/lists/blocked-accounts.ts deleted file mode 100644 index 5c3dbe7cec..0000000000 --- a/src/state/models/lists/blocked-accounts.ts +++ /dev/null @@ -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) - } -} diff --git a/src/state/models/lists/muted-accounts.ts b/src/state/models/lists/muted-accounts.ts deleted file mode 100644 index 19ade0d9c0..0000000000 --- a/src/state/models/lists/muted-accounts.ts +++ /dev/null @@ -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) - } -} diff --git a/src/state/queries/my-blocked-accounts.ts b/src/state/queries/my-blocked-accounts.ts new file mode 100644 index 0000000000..448f7dd679 --- /dev/null +++ b/src/state/queries/my-blocked-accounts.ts @@ -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, + 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, + }) +} diff --git a/src/state/queries/my-muted-accounts.ts b/src/state/queries/my-muted-accounts.ts new file mode 100644 index 0000000000..1098746735 --- /dev/null +++ b/src/state/queries/my-muted-accounts.ts @@ -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, + 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, + }) +} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 63367b261e..de2b1d65c5 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -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({ 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 diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx index 4a25c53e64..940761e314 100644 --- a/src/view/com/lists/ListMembers.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -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, ], ) diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index f7340fd6f2..95f0ecd93d 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -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 }) { - 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 ( ) -}) +} function ProfileCardPills({ followedBy, diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index 0dc3b706bd..702a8d44ef 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -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. - {!blockedAccounts.hasContent ? ( + {isEmpty ? ( - - - 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. - - + {isError ? ( + + ) : ( + + + 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. + + + )} ) : ( item.did} refreshControl={ ( - {blockedAccounts.isLoading && } + {(isFetching || isFetchingNextPage) && } )} - extraData={blockedAccounts.isLoading} // @ts-ignore our .web version only -prf desktopFixedHeight /> )} ) - }), + }, ) const styles = StyleSheet.create({ diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 2fa27ee54f..fe0b4bf142 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -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. - {!mutedAccounts.hasContent ? ( + {isEmpty ? ( - - - 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. - - + {isError ? ( + + ) : ( + + + 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. + + + )} ) : ( item.did} refreshControl={ ( - {mutedAccounts.isLoading && } + {(isFetching || isFetchingNextPage) && } )} - extraData={mutedAccounts.isLoading} // @ts-ignore our .web version only -prf desktopFixedHeight /> )} ) - }), + }, ) const styles = StyleSheet.create({