[D1X] Use user action and viewing history to inform suggested follows (#4727)

* Use user action and viewing history to inform suggested follows

* Remove dynamic spreads

* Track more info about seen posts

* Add ranking

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
zio/stable
Eric Bailey 2024-07-04 16:28:38 -05:00 committed by GitHub
parent 1c6bfc02fb
commit 3407206f52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 196 additions and 49 deletions

View File

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler' import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
@ -9,10 +9,13 @@ import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types' import {NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' import {useProfilesQuery} from '#/state/queries/profile'
import {useProgressGuide} from '#/state/shell/progress-guide' import {useProgressGuide} from '#/state/shell/progress-guide'
import * as userActionHistory from '#/state/userActionHistory'
import {SeenPost} from '#/state/userActionHistory'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf' import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard' import * as FeedCard from '#/components/FeedCard'
@ -80,35 +83,92 @@ export function SuggestedFeedsCardPlaceholder() {
) )
} }
function getRank(seenPost: SeenPost): string {
let tier: string
if (seenPost.feedContext === 'popfriends') {
tier = 'a'
} else if (seenPost.feedContext?.startsWith('cluster')) {
tier = 'b'
} else if (seenPost.feedContext?.startsWith('ntpc')) {
tier = 'c'
} else if (seenPost.feedContext?.startsWith('t-')) {
tier = 'd'
} else if (seenPost.feedContext === 'nettop') {
tier = 'e'
} else {
tier = 'f'
}
let score = Math.round(
Math.log(
1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
),
)
if (seenPost.isFollowedBy || Math.random() > 0.9) {
score *= 2
}
const rank = 100 - score
return `${tier}-${rank}`
}
function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
const rankA = getRank(postA)
const rankB = getRank(postB)
// Yes, we're comparing strings here.
// The "larger" string means a worse rank.
if (rankA > rankB) {
return 1
} else if (rankA < rankB) {
return -1
} else {
return 0
}
}
function useExperimentalSuggestedUsersQuery() {
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => {
const {likes, follows, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
.map(uri => uri.host)
.filter(did => !follows.includes(did))
const seenDids = seen
.sort(sortSeenPosts)
.map(l => new AtUri(l.uri))
.map(uri => uri.host)
return [...new Set([...likeDids, ...seenDids])]
}, [userActionSnapshot])
const {data, isLoading, error} = useProfilesQuery({
handles: dids.slice(0, 16),
})
const profiles = data
? data.profiles.filter(profile => {
return !profile.viewer?.following
})
: []
return {
isLoading,
error,
profiles: profiles.slice(0, 6),
}
}
export function SuggestedFollows() { export function SuggestedFollows() {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const { const {
isLoading: isSuggestionsLoading, isLoading: isSuggestionsLoading,
data, profiles,
error, error,
} = useSuggestedFollowsQuery({limit: 6}) } = useExperimentalSuggestedUsersQuery()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const isLoading = isSuggestionsLoading || !moderationOpts const isLoading = isSuggestionsLoading || !moderationOpts
const maxLength = gtMobile ? 4 : 6 const maxLength = gtMobile ? 4 : 6
const profiles: AppBskyActorDefs.ProfileViewBasic[] = []
if (data) {
// Currently the responses contain duplicate items.
// Needs to be fixed on backend, but let's dedupe to be safe.
let seen = new Set()
for (const page of data.pages) {
for (const actor of page.actors) {
if (!seen.has(actor.did)) {
seen.add(actor.did)
profiles.push(actor)
}
}
}
}
const content = isLoading ? ( const content = isLoading ? (
Array(maxLength) Array(maxLength)
.fill(0) .fill(0)
@ -164,7 +224,12 @@ export function SuggestedFollows() {
</> </>
) )
return error ? null : ( if (error || (!isLoading && profiles.length < 4)) {
logger.debug(`Not enough profiles to show suggested follows`)
return null
}
return (
<View <View
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}> <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>

View File

@ -17,11 +17,13 @@ import {
import {HomeFeedAPI} from '#/lib/api/feed/home' import {HomeFeedAPI} from '#/lib/api/feed/home'
import {aggregateUserInterests} from '#/lib/api/feed/utils' import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {logger} from '#/logger' import {logger} from '#/logger'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {AuthorFeedAPI} from 'lib/api/feed/author' import {AuthorFeedAPI} from 'lib/api/feed/author'
import {CustomFeedAPI} from 'lib/api/feed/custom' import {CustomFeedAPI} from 'lib/api/feed/custom'
import {FollowingFeedAPI} from 'lib/api/feed/following' import {FollowingFeedAPI} from 'lib/api/feed/following'
@ -131,6 +133,7 @@ export function usePostFeedQuery(
result: InfiniteData<FeedPage> result: InfiniteData<FeedPage>
} | null>(null) } | null>(null)
const lastPageCountRef = useRef(0) const lastPageCountRef = useRef(0)
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
// Make sure this doesn't invalidate unless really needed. // Make sure this doesn't invalidate unless really needed.
const selectArgs = React.useMemo( const selectArgs = React.useMemo(
@ -139,8 +142,15 @@ export function usePostFeedQuery(
disableTuner: params?.disableTuner, disableTuner: params?.disableTuner,
moderationOpts, moderationOpts,
ignoreFilterFor: opts?.ignoreFilterFor, ignoreFilterFor: opts?.ignoreFilterFor,
isDiscover,
}), }),
[feedTuners, params?.disableTuner, moderationOpts, opts?.ignoreFilterFor], [
feedTuners,
params?.disableTuner,
moderationOpts,
opts?.ignoreFilterFor,
isDiscover,
],
) )
const query = useInfiniteQuery< const query = useInfiniteQuery<
@ -219,8 +229,13 @@ export function usePostFeedQuery(
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => { (data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
// If the selection depends on some data, that data should // If the selection depends on some data, that data should
// be included in the selectArgs object and read here. // be included in the selectArgs object and read here.
const {feedTuners, disableTuner, moderationOpts, ignoreFilterFor} = const {
selectArgs feedTuners,
disableTuner,
moderationOpts,
ignoreFilterFor,
isDiscover,
} = selectArgs
const tuner = disableTuner const tuner = disableTuner
? new NoopFeedTuner() ? new NoopFeedTuner()
@ -293,6 +308,21 @@ export function usePostFeedQuery(
} }
} }
if (isDiscover) {
userActionHistory.seen(
slice.items.map(item => ({
feedContext: item.feedContext,
likeCount: item.post.likeCount ?? 0,
repostCount: item.post.repostCount ?? 0,
replyCount: item.post.replyCount ?? 0,
isFollowedBy: Boolean(
item.post.author.viewer?.followedBy,
),
uri: item.post.uri,
})),
)
}
return { return {
_reactKey: slice._reactKey, _reactKey: slice._reactKey,
_isFeedPostSlice: true, _isFeedPostSlice: true,

View File

@ -8,6 +8,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
import {updatePostShadow} from '#/state/cache/post-shadow' import {updatePostShadow} from '#/state/cache/post-shadow'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes' import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes'
import {findProfileQueryData} from './profile' import {findProfileQueryData} from './profile'
@ -92,6 +93,7 @@ export function usePostLikeMutationQueue(
uri: postUri, uri: postUri,
cid: postCid, cid: postCid,
}) })
userActionHistory.like([postUri])
return likeUri return likeUri
} else { } else {
if (prevLikeUri) { if (prevLikeUri) {
@ -99,6 +101,7 @@ export function usePostLikeMutationQueue(
postUri: postUri, postUri: postUri,
likeUri: prevLikeUri, likeUri: prevLikeUri,
}) })
userActionHistory.unlike([postUri])
} }
return undefined return undefined
} }

View File

@ -23,6 +23,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import * as userActionHistory from '#/state/userActionHistory'
import {updateProfileShadow} from '../cache/profile-shadow' import {updateProfileShadow} from '../cache/profile-shadow'
import {useAgent, useSession} from '../session' import {useAgent, useSession} from '../session'
import { import {
@ -233,6 +234,7 @@ export function useProfileFollowMutationQueue(
const {uri} = await followMutation.mutateAsync({ const {uri} = await followMutation.mutateAsync({
did, did,
}) })
userActionHistory.follow([did])
return uri return uri
} else { } else {
if (prevFollowingUri) { if (prevFollowingUri) {
@ -240,6 +242,7 @@ export function useProfileFollowMutationQueue(
did, did,
followUri: prevFollowingUri, followUri: prevFollowingUri,
}) })
userActionHistory.unfollow([did])
} }
return undefined return undefined
} }

View File

@ -0,0 +1,71 @@
import React from 'react'
const LIKE_WINDOW = 100
const FOLLOW_WINDOW = 100
const SEEN_WINDOW = 100
export type SeenPost = {
uri: string
likeCount: number
repostCount: number
replyCount: number
isFollowedBy: boolean
feedContext: string | undefined
}
export type UserActionHistory = {
/**
* The last 100 post URIs the user has liked
*/
likes: string[]
/**
* The last 100 DIDs the user has followed
*/
follows: string[]
/**
* The last 100 post URIs the user has seen from the Discover feed only
*/
seen: SeenPost[]
}
const userActionHistory: UserActionHistory = {
likes: [],
follows: [],
seen: [],
}
export function getActionHistory() {
return userActionHistory
}
export function useActionHistorySnapshot() {
return React.useState(() => getActionHistory())[0]
}
export function like(postUris: string[]) {
userActionHistory.likes = userActionHistory.likes
.concat(postUris)
.slice(-LIKE_WINDOW)
}
export function unlike(postUris: string[]) {
userActionHistory.likes = userActionHistory.likes.filter(
uri => !postUris.includes(uri),
)
}
export function follow(dids: string[]) {
userActionHistory.follows = userActionHistory.follows
.concat(dids)
.slice(-FOLLOW_WINDOW)
}
export function unfollow(dids: string[]) {
userActionHistory.follows = userActionHistory.follows.filter(
uri => !dids.includes(uri),
)
}
export function seen(posts: SeenPost[]) {
userActionHistory.seen = userActionHistory.seen
.concat(posts)
.slice(-SEEN_WINDOW)
}

View File

@ -110,24 +110,7 @@ const interstials: Record<
| 'interstitialProgressGuide' | 'interstitialProgressGuide'
})[] })[]
> = { > = {
following: [ following: [],
{
type: followInterstitialType,
params: {
variant: 'default',
},
key: followInterstitialType,
slot: 20,
},
{
type: feedInterstitialType,
params: {
variant: 'default',
},
key: feedInterstitialType,
slot: 40,
},
],
discover: [ discover: [
{ {
type: progressGuideInterstitialType, type: progressGuideInterstitialType,
@ -137,14 +120,6 @@ const interstials: Record<
key: progressGuideInterstitialType, key: progressGuideInterstitialType,
slot: 0, slot: 0,
}, },
{
type: feedInterstitialType,
params: {
variant: 'default',
},
key: feedInterstitialType,
slot: 40,
},
{ {
type: followInterstitialType, type: followInterstitialType,
params: { params: {