Skip to content

Commit

Permalink
[D1X] Use user action and viewing history to inform suggested follows (
Browse files Browse the repository at this point in the history
…#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>
  • Loading branch information
estrattonbailey and gaearon committed Jul 4, 2024
1 parent 1c6bfc0 commit 3407206
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 49 deletions.
105 changes: 85 additions & 20 deletions src/components/FeedInterstitials.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import React from 'react'
import {View} from 'react-native'
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 {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'

import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
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 * as userActionHistory from '#/state/userActionHistory'
import {SeenPost} from '#/state/userActionHistory'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
Expand Down Expand Up @@ -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() {
const t = useTheme()
const {_} = useLingui()
const {
isLoading: isSuggestionsLoading,
data,
profiles,
error,
} = useSuggestedFollowsQuery({limit: 6})
} = useExperimentalSuggestedUsersQuery()
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const isLoading = isSuggestionsLoading || !moderationOpts
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 ? (
Array(maxLength)
.fill(0)
Expand Down Expand Up @@ -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
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]}>
Expand Down
36 changes: 33 additions & 3 deletions src/state/queries/post-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import {

import {HomeFeedAPI} from '#/lib/api/feed/home'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {useAgent} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {AuthorFeedAPI} from 'lib/api/feed/author'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {FollowingFeedAPI} from 'lib/api/feed/following'
Expand Down Expand Up @@ -131,6 +133,7 @@ export function usePostFeedQuery(
result: InfiniteData<FeedPage>
} | null>(null)
const lastPageCountRef = useRef(0)
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)

// Make sure this doesn't invalidate unless really needed.
const selectArgs = React.useMemo(
Expand All @@ -139,8 +142,15 @@ export function usePostFeedQuery(
disableTuner: params?.disableTuner,
moderationOpts,
ignoreFilterFor: opts?.ignoreFilterFor,
isDiscover,
}),
[feedTuners, params?.disableTuner, moderationOpts, opts?.ignoreFilterFor],
[
feedTuners,
params?.disableTuner,
moderationOpts,
opts?.ignoreFilterFor,
isDiscover,
],
)

const query = useInfiniteQuery<
Expand Down Expand Up @@ -219,8 +229,13 @@ export function usePostFeedQuery(
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
// If the selection depends on some data, that data should
// be included in the selectArgs object and read here.
const {feedTuners, disableTuner, moderationOpts, ignoreFilterFor} =
selectArgs
const {
feedTuners,
disableTuner,
moderationOpts,
ignoreFilterFor,
isDiscover,
} = selectArgs

const tuner = disableTuner
? new NoopFeedTuner()
Expand Down Expand Up @@ -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 {
_reactKey: slice._reactKey,
_isFeedPostSlice: true,
Expand Down
3 changes: 3 additions & 0 deletions src/state/queries/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
import {updatePostShadow} from '#/state/cache/post-shadow'
import {Shadow} from '#/state/cache/types'
import {useAgent, useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes'
import {findProfileQueryData} from './profile'

Expand Down Expand Up @@ -92,13 +93,15 @@ export function usePostLikeMutationQueue(
uri: postUri,
cid: postCid,
})
userActionHistory.like([postUri])
return likeUri
} else {
if (prevLikeUri) {
await unlikeMutation.mutateAsync({
postUri: postUri,
likeUri: prevLikeUri,
})
userActionHistory.unlike([postUri])
}
return undefined
}
Expand Down
3 changes: 3 additions & 0 deletions src/state/queries/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
import {Shadow} from '#/state/cache/types'
import {STALE} from '#/state/queries'
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import * as userActionHistory from '#/state/userActionHistory'
import {updateProfileShadow} from '../cache/profile-shadow'
import {useAgent, useSession} from '../session'
import {
Expand Down Expand Up @@ -233,13 +234,15 @@ export function useProfileFollowMutationQueue(
const {uri} = await followMutation.mutateAsync({
did,
})
userActionHistory.follow([did])
return uri
} else {
if (prevFollowingUri) {
await unfollowMutation.mutateAsync({
did,
followUri: prevFollowingUri,
})
userActionHistory.unfollow([did])
}
return undefined
}
Expand Down
71 changes: 71 additions & 0 deletions src/state/userActionHistory.ts
Original file line number Diff line number Diff line change
@@ -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)
}
27 changes: 1 addition & 26 deletions src/view/com/posts/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,24 +110,7 @@ const interstials: Record<
| 'interstitialProgressGuide'
})[]
> = {
following: [
{
type: followInterstitialType,
params: {
variant: 'default',
},
key: followInterstitialType,
slot: 20,
},
{
type: feedInterstitialType,
params: {
variant: 'default',
},
key: feedInterstitialType,
slot: 40,
},
],
following: [],
discover: [
{
type: progressGuideInterstitialType,
Expand All @@ -137,14 +120,6 @@ const interstials: Record<
key: progressGuideInterstitialType,
slot: 0,
},
{
type: feedInterstitialType,
params: {
variant: 'default',
},
key: feedInterstitialType,
slot: 40,
},
{
type: followInterstitialType,
params: {
Expand Down

0 comments on commit 3407206

Please sign in to comment.