From a81c4b68fa9ae87dea0b1dec76012ef7a69fca26 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 13 Nov 2023 17:30:56 -0800 Subject: [PATCH 1/4] 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 --- src/state/models/lists/blocked-accounts.ts | 107 ------------------ src/state/models/lists/muted-accounts.ts | 107 ------------------ src/state/queries/my-blocked-accounts.ts | 28 +++++ src/state/queries/my-muted-accounts.ts | 28 +++++ src/state/queries/profile.ts | 8 ++ src/view/com/lists/ListMembers.tsx | 3 + src/view/com/profile/ProfileCard.tsx | 19 +++- .../screens/ModerationBlockedAccounts.tsx | 102 +++++++++++------ src/view/screens/ModerationMutedAccounts.tsx | 99 ++++++++++------ 9 files changed, 212 insertions(+), 289 deletions(-) delete mode 100644 src/state/models/lists/blocked-accounts.ts delete mode 100644 src/state/models/lists/muted-accounts.ts create mode 100644 src/state/queries/my-blocked-accounts.ts create mode 100644 src/state/queries/my-muted-accounts.ts 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({ From 4355f0fd9abf49e511027722dbec0816900da0ad Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 14 Nov 2023 11:25:37 -0600 Subject: [PATCH 2/4] Refactor onboarding suggested follows (#1897) * Refactor onboarding suggested follows * Fix error state, track call * Remove todo * Use flatmap * Add additional try catch * Remove todo --- src/state/queries/suggested-follows.ts | 75 ++++++++++++ .../auth/onboarding/RecommendedFollows.tsx | 108 +++++++++++++----- .../onboarding/RecommendedFollowsItem.tsx | 107 +++++++++++------ 3 files changed, 227 insertions(+), 63 deletions(-) create mode 100644 src/state/queries/suggested-follows.ts diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts new file mode 100644 index 0000000000..805668bcb1 --- /dev/null +++ b/src/state/queries/suggested-follows.ts @@ -0,0 +1,75 @@ +import {AppBskyActorGetSuggestions, moderateProfile} from '@atproto/api' +import { + useInfiniteQuery, + useMutation, + InfiniteData, + QueryKey, +} from '@tanstack/react-query' + +import {useSession} from '#/state/session' +import {useModerationOpts} from '#/state/queries/preferences' + +export const suggestedFollowsQueryKey = ['suggested-follows'] + +export function useSuggestedFollowsQuery() { + const {agent, currentAccount} = useSession() + const moderationOpts = useModerationOpts() + + return useInfiniteQuery< + AppBskyActorGetSuggestions.OutputSchema, + Error, + InfiniteData, + QueryKey, + string | undefined + >({ + enabled: !!moderationOpts, + queryKey: suggestedFollowsQueryKey, + queryFn: async ({pageParam}) => { + const res = await agent.app.bsky.actor.getSuggestions({ + limit: 25, + cursor: pageParam, + }) + + res.data.actors = res.data.actors + .filter( + actor => !moderateProfile(actor, moderationOpts!).account.filter, + ) + .filter(actor => { + const viewer = actor.viewer + if (viewer) { + if ( + viewer.following || + viewer.muted || + viewer.mutedByList || + viewer.blockedBy || + viewer.blocking + ) { + return false + } + } + if (actor.did === currentAccount?.did) { + return false + } + return true + }) + + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export function useGetSuggestedFollowersByActor() { + const {agent} = useSession() + + return useMutation({ + mutationFn: async (actor: string) => { + const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ + actor: actor, + }) + + return res.data + }, + }) +} diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx index 9eef14e0b6..efe41562e7 100644 --- a/src/view/com/auth/onboarding/RecommendedFollows.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx @@ -2,6 +2,7 @@ import React from 'react' import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints' import {Text} from 'view/com/util/text/Text' import {ViewHeader} from 'view/com/util/ViewHeader' @@ -9,9 +10,11 @@ import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout' import {Button} from 'view/com/util/forms/Button' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {RecommendedFollowsItem} from './RecommendedFollowsItem' -import {SuggestedActorsModel} from '#/state/models/discovery/suggested-actors' +import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' +import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows' +import {useModerationOpts} from '#/state/queries/preferences' +import {logger} from '#/logger' type Props = { next: () => void @@ -19,14 +22,16 @@ type Props = { export const RecommendedFollows = observer(function RecommendedFollowsImpl({ next, }: Props) { - const store = useStores() const pal = usePalette('default') const {isTabletOrMobile} = useWebMediaQueries() - const suggestedActors = React.useMemo(() => { - const model = new SuggestedActorsModel(store) - model.refresh() - return model - }, [store]) + const {data: suggestedFollows, dataUpdatedAt} = useSuggestedFollowsQuery() + const {mutateAsync: getSuggestedFollowsByActor} = + useGetSuggestedFollowersByActor() + const [additionalSuggestions, setAdditionalSuggestions] = React.useState<{ + [did: string]: AppBskyActorDefs.ProfileView[] + }>({}) + const existingDids = React.useRef([]) + const moderationOpts = useModerationOpts() const title = ( <> @@ -84,6 +89,59 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ ) + const suggestions = React.useMemo(() => { + if (!suggestedFollows) return [] + + const additional = Object.entries(additionalSuggestions) + const items = suggestedFollows.pages.flatMap(page => page.actors) + + outer: while (additional.length) { + const additionalAccount = additional.shift() + + if (!additionalAccount) break + + const [followedUser, relatedAccounts] = additionalAccount + + for (let i = 0; i < items.length; i++) { + if (items[i].did === followedUser) { + items.splice(i + 1, 0, ...relatedAccounts) + continue outer + } + } + } + + existingDids.current = items.map(i => i.did) + + return items + }, [suggestedFollows, additionalSuggestions]) + + const onFollowStateChange = React.useCallback( + async ({following, did}: {following: boolean; did: string}) => { + if (following) { + try { + const {suggestions: results} = await getSuggestedFollowsByActor(did) + + if (results.length) { + const deduped = results.filter( + r => !existingDids.current.find(did => did === r.did), + ) + setAdditionalSuggestions(s => ({ + ...s, + [did]: deduped.slice(0, 3), + })) + } + } catch (e) { + logger.error('RecommendedFollows: failed to get suggestions', { + error: e, + }) + } + } + + // not handling the unfollow case + }, + [existingDids, getSuggestedFollowsByActor, setAdditionalSuggestions], + ) + return ( <> @@ -93,21 +151,20 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ horizontal titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}} contentStyle={{paddingHorizontal: 0}}> - {suggestedActors.isLoading ? ( + {!suggestedFollows || !moderationOpts ? ( ) : ( ( + data={suggestions} + renderItem={({item}) => ( )} - keyExtractor={(item, index) => item.did + index.toString()} + keyExtractor={item => item.did} style={{flex: 1}} /> )} @@ -127,21 +184,20 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ users. - {suggestedActors.isLoading ? ( + {!suggestedFollows || !moderationOpts ? ( ) : ( ( + data={suggestions} + renderItem={({item}) => ( )} - keyExtractor={(item, index) => item.did + index.toString()} + keyExtractor={item => item.did} style={{flex: 1}} /> )} diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 7ec78bd7fe..f52b312136 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -1,9 +1,7 @@ import React from 'react' import {View, StyleSheet, ActivityIndicator} from 'react-native' -import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {FollowButton} from 'view/com/profile/FollowButton' +import {ProfileModeration} from '@atproto/api' +import {Button} from '#/view/com/util/forms/Button' import {usePalette} from 'lib/hooks/usePalette' import {SuggestedActor} from 'state/models/discovery/suggested-actors' import {sanitizeDisplayName} from 'lib/strings/display-names' @@ -15,19 +13,32 @@ import Animated, {FadeInRight} from 'react-native-reanimated' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAnalytics} from 'lib/analytics/analytics' import {Trans} from '@lingui/macro' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import { + useProfileFollowMutation, + useProfileUnfollowMutation, +} from '#/state/queries/profile' +import {logger} from '#/logger' type Props = { - item: SuggestedActor - index: number - insertSuggestionsByActor: (did: string, index: number) => Promise + profile: SuggestedActor + dataUpdatedAt: number + moderation: ProfileModeration + onFollowStateChange: (props: { + did: string + following: boolean + }) => Promise } -export const RecommendedFollowsItem: React.FC = ({ - item, - index, - insertSuggestionsByActor, -}) => { + +export function RecommendedFollowsItem({ + profile, + dataUpdatedAt, + moderation, + onFollowStateChange, +}: React.PropsWithChildren) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const shadowedProfile = useProfileShadow(profile, dataUpdatedAt) return ( = ({ }, ]}> ) } -export const ProfileCard = observer(function ProfileCardImpl({ +export function ProfileCard({ profile, - index, - insertSuggestionsByActor, -}: { - profile: AppBskyActorDefs.ProfileViewBasic - index: number - insertSuggestionsByActor: (did: string, index: number) => Promise -}) { + onFollowStateChange, + moderation, +}: Omit) { const {track} = useAnalytics() - const store = useStores() const pal = usePalette('default') - const moderation = moderateProfile(profile, store.preferences.moderationOpts) const [addingMoreSuggestions, setAddingMoreSuggestions] = React.useState(false) + const {mutateAsync: follow} = useProfileFollowMutation() + const {mutateAsync: unfollow} = useProfileUnfollowMutation() + + const onToggleFollow = React.useCallback(async () => { + try { + if ( + profile.viewer?.following && + profile.viewer?.following !== 'pending' + ) { + await unfollow({did: profile.did, followUri: profile.viewer.following}) + } else if ( + !profile.viewer?.following && + profile.viewer?.following !== 'pending' + ) { + setAddingMoreSuggestions(true) + await follow({did: profile.did}) + await onFollowStateChange({did: profile.did, following: true}) + setAddingMoreSuggestions(false) + track('Onboarding:SuggestedFollowFollowed') + } + } catch (e) { + logger.error('RecommendedFollows: failed to toggle following', {error: e}) + } finally { + setAddingMoreSuggestions(false) + } + }, [ + profile, + follow, + unfollow, + setAddingMoreSuggestions, + track, + onFollowStateChange, + ]) return ( @@ -93,17 +131,12 @@ export const ProfileCard = observer(function ProfileCardImpl({ - { - if (isFollow) { - setAddingMoreSuggestions(true) - await insertSuggestionsByActor(profile.did, index) - setAddingMoreSuggestions(false) - track('Onboarding:SuggestedFollowFollowed') - } - }} + onPress={onToggleFollow} + label={profile.viewer?.following ? 'Unfollow' : 'Follow'} + withLoading={true} /> {profile.description ? ( @@ -123,7 +156,7 @@ export const ProfileCard = observer(function ProfileCardImpl({ ) : null} ) -}) +} const styles = StyleSheet.create({ cardContainer: { From 3fde1bea1bffecb6740f261b88ceb34134e0d418 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 14 Nov 2023 11:28:40 -0600 Subject: [PATCH 3/4] Handle end of feed (#1898) * Handle end of feed * Add isFetching --- src/view/screens/Feeds.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 9c3d890053..7a3daee8df 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -107,6 +107,7 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl( refetch: refetchPopularFeeds, fetchNextPage: fetchNextPopularFeedsPage, isFetchingNextPage: isPopularFeedsFetchingNextPage, + hasNextPage: hasNextPopularFeedsPage, } = useGetPopularFeedsQuery() const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() @@ -154,6 +155,22 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl( await refetchPopularFeeds() setIsPTR(false) }, [setIsPTR, refetchPopularFeeds]) + const onEndReached = React.useCallback(() => { + if ( + isPopularFeedsFetching || + isUserSearching || + !hasNextPopularFeedsPage || + popularFeedsError + ) + return + fetchNextPopularFeedsPage() + }, [ + isPopularFeedsFetching, + isUserSearching, + popularFeedsError, + hasNextPopularFeedsPage, + fetchNextPopularFeedsPage, + ]) useFocusEffect( React.useCallback(() => { @@ -474,9 +491,7 @@ export const FeedsScreen = withAuthRequired(function FeedsScreenImpl( /> } initialNumToRender={10} - onEndReached={() => - isUserSearching ? undefined : fetchNextPopularFeedsPage() - } + onEndReached={onEndReached} // @ts-ignore our .web version only -prf desktopFixedHeight /> From c687172de96bd6aa85d3aa025c2e0f024640f345 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 14 Nov 2023 10:33:05 -0800 Subject: [PATCH 4/4] Fix poll latest loop (#1901) * Avoid pollLatest loop * Set poll interval on profile feeds --- src/view/com/posts/Feed.tsx | 3 +++ src/view/screens/Profile.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 6cbad7f716..5c9d1ad2c1 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -106,6 +106,9 @@ export function Feed({ checkForNewRef.current = checkForNew }, [checkForNew]) React.useEffect(() => { + if (!pollInterval) { + return + } const i = setInterval(() => checkForNewRef.current?.(), pollInterval) return () => clearInterval(i) }, [pollInterval]) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 1a2982027e..724c47c95d 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -350,6 +350,7 @@ const FeedSection = React.forwardRef(