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 {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}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
Loading…
Reference in New Issue