Refactor onboarding suggested follows (#1897)

* Refactor onboarding suggested follows

* Fix error state, track call

* Remove todo

* Use flatmap

* Add additional try catch

* Remove todo
zio/stable
Eric Bailey 2023-11-14 11:25:37 -06:00 committed by GitHub
parent a81c4b68fa
commit 4355f0fd9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 227 additions and 63 deletions

View File

@ -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
},
})
}

View File

@ -2,6 +2,7 @@ import React from 'react'
import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints' import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {ViewHeader} from 'view/com/util/ViewHeader' 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 {Button} from 'view/com/util/forms/Button'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {RecommendedFollowsItem} from './RecommendedFollowsItem' 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 = { type Props = {
next: () => void next: () => void
@ -19,14 +22,16 @@ type Props = {
export const RecommendedFollows = observer(function RecommendedFollowsImpl({ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
next, next,
}: Props) { }: Props) {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const {isTabletOrMobile} = useWebMediaQueries() const {isTabletOrMobile} = useWebMediaQueries()
const suggestedActors = React.useMemo(() => { const {data: suggestedFollows, dataUpdatedAt} = useSuggestedFollowsQuery()
const model = new SuggestedActorsModel(store) const {mutateAsync: getSuggestedFollowsByActor} =
model.refresh() useGetSuggestedFollowersByActor()
return model const [additionalSuggestions, setAdditionalSuggestions] = React.useState<{
}, [store]) [did: string]: AppBskyActorDefs.ProfileView[]
}>({})
const existingDids = React.useRef<string[]>([])
const moderationOpts = useModerationOpts()
const title = ( 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 ( return (
<> <>
<TabletOrDesktop> <TabletOrDesktop>
@ -93,21 +151,20 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
horizontal horizontal
titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}} titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
contentStyle={{paddingHorizontal: 0}}> contentStyle={{paddingHorizontal: 0}}>
{suggestedActors.isLoading ? ( {!suggestedFollows || !moderationOpts ? (
<ActivityIndicator size="large" /> <ActivityIndicator size="large" />
) : ( ) : (
<FlatList <FlatList
data={suggestedActors.suggestions} data={suggestions}
renderItem={({item, index}) => ( renderItem={({item}) => (
<RecommendedFollowsItem <RecommendedFollowsItem
item={item} profile={item}
index={index} dataUpdatedAt={dataUpdatedAt}
insertSuggestionsByActor={suggestedActors.insertSuggestionsByActor.bind( onFollowStateChange={onFollowStateChange}
suggestedActors, moderation={moderateProfile(item, moderationOpts)}
)}
/> />
)} )}
keyExtractor={(item, index) => item.did + index.toString()} keyExtractor={item => item.did}
style={{flex: 1}} style={{flex: 1}}
/> />
)} )}
@ -127,21 +184,20 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
users. users.
</Text> </Text>
</View> </View>
{suggestedActors.isLoading ? ( {!suggestedFollows || !moderationOpts ? (
<ActivityIndicator size="large" /> <ActivityIndicator size="large" />
) : ( ) : (
<FlatList <FlatList
data={suggestedActors.suggestions} data={suggestions}
renderItem={({item, index}) => ( renderItem={({item}) => (
<RecommendedFollowsItem <RecommendedFollowsItem
item={item} profile={item}
index={index} dataUpdatedAt={dataUpdatedAt}
insertSuggestionsByActor={suggestedActors.insertSuggestionsByActor.bind( onFollowStateChange={onFollowStateChange}
suggestedActors, moderation={moderateProfile(item, moderationOpts)}
)}
/> />
)} )}
keyExtractor={(item, index) => item.did + index.toString()} keyExtractor={item => item.did}
style={{flex: 1}} style={{flex: 1}}
/> />
)} )}

View File

@ -1,9 +1,7 @@
import React from 'react' import React from 'react'
import {View, StyleSheet, ActivityIndicator} from 'react-native' import {View, StyleSheet, ActivityIndicator} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {ProfileModeration} from '@atproto/api'
import {observer} from 'mobx-react-lite' import {Button} from '#/view/com/util/forms/Button'
import {useStores} from 'state/index'
import {FollowButton} from 'view/com/profile/FollowButton'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {SuggestedActor} from 'state/models/discovery/suggested-actors' import {SuggestedActor} from 'state/models/discovery/suggested-actors'
import {sanitizeDisplayName} from 'lib/strings/display-names' 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 {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {Trans} from '@lingui/macro' 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 = { type Props = {
item: SuggestedActor profile: SuggestedActor
index: number dataUpdatedAt: number
insertSuggestionsByActor: (did: string, index: number) => Promise<void> moderation: ProfileModeration
onFollowStateChange: (props: {
did: string
following: boolean
}) => Promise<void>
} }
export const RecommendedFollowsItem: React.FC<Props> = ({
item, export function RecommendedFollowsItem({
index, profile,
insertSuggestionsByActor, dataUpdatedAt,
}) => { moderation,
onFollowStateChange,
}: React.PropsWithChildren<Props>) {
const pal = usePalette('default') const pal = usePalette('default')
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const shadowedProfile = useProfileShadow(profile, dataUpdatedAt)
return ( return (
<Animated.View <Animated.View
@ -42,30 +53,57 @@ export const RecommendedFollowsItem: React.FC<Props> = ({
}, },
]}> ]}>
<ProfileCard <ProfileCard
key={item.did} key={profile.did}
profile={item} profile={shadowedProfile}
index={index} onFollowStateChange={onFollowStateChange}
insertSuggestionsByActor={insertSuggestionsByActor} moderation={moderation}
/> />
</Animated.View> </Animated.View>
) )
} }
export const ProfileCard = observer(function ProfileCardImpl({ export function ProfileCard({
profile, profile,
index, onFollowStateChange,
insertSuggestionsByActor, moderation,
}: { }: Omit<Props, 'dataUpdatedAt'>) {
profile: AppBskyActorDefs.ProfileViewBasic
index: number
insertSuggestionsByActor: (did: string, index: number) => Promise<void>
}) {
const {track} = useAnalytics() const {track} = useAnalytics()
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const moderation = moderateProfile(profile, store.preferences.moderationOpts)
const [addingMoreSuggestions, setAddingMoreSuggestions] = const [addingMoreSuggestions, setAddingMoreSuggestions] =
React.useState(false) 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 ( return (
<View style={styles.card}> <View style={styles.card}>
@ -93,17 +131,12 @@ export const ProfileCard = observer(function ProfileCardImpl({
</Text> </Text>
</View> </View>
<FollowButton <Button
profile={profile} type={profile.viewer?.following ? 'default' : 'inverted'}
labelStyle={styles.followButton} labelStyle={styles.followButton}
onToggleFollow={async isFollow => { onPress={onToggleFollow}
if (isFollow) { label={profile.viewer?.following ? 'Unfollow' : 'Follow'}
setAddingMoreSuggestions(true) withLoading={true}
await insertSuggestionsByActor(profile.did, index)
setAddingMoreSuggestions(false)
track('Onboarding:SuggestedFollowFollowed')
}
}}
/> />
</View> </View>
{profile.description ? ( {profile.description ? (
@ -123,7 +156,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
) : null} ) : null}
</View> </View>
) )
}) }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
cardContainer: { cardContainer: {