Refactor onboarding suggested follows (#1897)
* Refactor onboarding suggested follows * Fix error state, track call * Remove todo * Use flatmap * Add additional try catch * Remove todozio/stable
parent
a81c4b68fa
commit
4355f0fd9a
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
|
@ -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}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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: {
|
||||
|
|
Loading…
Reference in New Issue