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 {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}}
/>
)}

View File

@ -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: {