diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts new file mode 100644 index 00000000..805668bc --- /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 9eef14e0..efe41562 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 7ec78bd7..f52b3121 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: {