diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/state/queries/suggested-follows.ts | 75 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/RecommendedFollows.tsx | 108 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/RecommendedFollowsItem.tsx | 107 |
3 files changed, 227 insertions, 63 deletions
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts new file mode 100644 index 000000000..805668bcb --- /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<AppBskyActorGetSuggestions.OutputSchema>, + 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 9eef14e0b..efe41562e 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<string[]>([]) + 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 ( <> <TabletOrDesktop> @@ -93,21 +151,20 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ horizontal titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}} contentStyle={{paddingHorizontal: 0}}> - {suggestedActors.isLoading ? ( + {!suggestedFollows || !moderationOpts ? ( <ActivityIndicator size="large" /> ) : ( <FlatList - data={suggestedActors.suggestions} - renderItem={({item, index}) => ( + data={suggestions} + renderItem={({item}) => ( <RecommendedFollowsItem - item={item} - index={index} - insertSuggestionsByActor={suggestedActors.insertSuggestionsByActor.bind( - suggestedActors, - )} + profile={item} + dataUpdatedAt={dataUpdatedAt} + onFollowStateChange={onFollowStateChange} + moderation={moderateProfile(item, moderationOpts)} /> )} - 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. </Text> </View> - {suggestedActors.isLoading ? ( + {!suggestedFollows || !moderationOpts ? ( <ActivityIndicator size="large" /> ) : ( <FlatList - data={suggestedActors.suggestions} - renderItem={({item, index}) => ( + data={suggestions} + renderItem={({item}) => ( <RecommendedFollowsItem - item={item} - index={index} - insertSuggestionsByActor={suggestedActors.insertSuggestionsByActor.bind( - suggestedActors, - )} + profile={item} + dataUpdatedAt={dataUpdatedAt} + onFollowStateChange={onFollowStateChange} + moderation={moderateProfile(item, moderationOpts)} /> )} - 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 7ec78bd7f..f52b31213 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<void> + profile: SuggestedActor + dataUpdatedAt: number + moderation: ProfileModeration + onFollowStateChange: (props: { + did: string + following: boolean + }) => Promise<void> } -export const RecommendedFollowsItem: React.FC<Props> = ({ - item, - index, - insertSuggestionsByActor, -}) => { + +export function RecommendedFollowsItem({ + profile, + dataUpdatedAt, + moderation, + onFollowStateChange, +}: React.PropsWithChildren<Props>) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const shadowedProfile = useProfileShadow(profile, dataUpdatedAt) return ( <Animated.View @@ -42,30 +53,57 @@ export const RecommendedFollowsItem: React.FC<Props> = ({ }, ]}> <ProfileCard - key={item.did} - profile={item} - index={index} - insertSuggestionsByActor={insertSuggestionsByActor} + key={profile.did} + profile={shadowedProfile} + onFollowStateChange={onFollowStateChange} + moderation={moderation} /> </Animated.View> ) } -export const ProfileCard = observer(function ProfileCardImpl({ +export function ProfileCard({ profile, - index, - insertSuggestionsByActor, -}: { - profile: AppBskyActorDefs.ProfileViewBasic - index: number - insertSuggestionsByActor: (did: string, index: number) => Promise<void> -}) { + onFollowStateChange, + moderation, +}: Omit<Props, 'dataUpdatedAt'>) { 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 ( <View style={styles.card}> @@ -93,17 +131,12 @@ export const ProfileCard = observer(function ProfileCardImpl({ </Text> </View> - <FollowButton - profile={profile} + <Button + type={profile.viewer?.following ? 'default' : 'inverted'} labelStyle={styles.followButton} - onToggleFollow={async isFollow => { - if (isFollow) { - setAddingMoreSuggestions(true) - await insertSuggestionsByActor(profile.did, index) - setAddingMoreSuggestions(false) - track('Onboarding:SuggestedFollowFollowed') - } - }} + onPress={onToggleFollow} + label={profile.viewer?.following ? 'Unfollow' : 'Follow'} + withLoading={true} /> </View> {profile.description ? ( @@ -123,7 +156,7 @@ export const ProfileCard = observer(function ProfileCardImpl({ ) : null} </View> ) -}) +} const styles = StyleSheet.create({ cardContainer: { |