[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
parent
1c6bfc02fb
commit
3407206f52
|
@ -1,7 +1,7 @@
|
|||
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'
|
||||
|
@ -9,10 +9,13 @@ 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'
|
||||
|
@ -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)
|
||||
|
@ -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]}>
|
||||
|
|
|
@ -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'
|
||||
|
@ -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(
|
||||
|
@ -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<
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
@ -92,6 +93,7 @@ export function usePostLikeMutationQueue(
|
|||
uri: postUri,
|
||||
cid: postCid,
|
||||
})
|
||||
userActionHistory.like([postUri])
|
||||
return likeUri
|
||||
} else {
|
||||
if (prevLikeUri) {
|
||||
|
@ -99,6 +101,7 @@ export function usePostLikeMutationQueue(
|
|||
postUri: postUri,
|
||||
likeUri: prevLikeUri,
|
||||
})
|
||||
userActionHistory.unlike([postUri])
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
@ -233,6 +234,7 @@ export function useProfileFollowMutationQueue(
|
|||
const {uri} = await followMutation.mutateAsync({
|
||||
did,
|
||||
})
|
||||
userActionHistory.follow([did])
|
||||
return uri
|
||||
} else {
|
||||
if (prevFollowingUri) {
|
||||
|
@ -240,6 +242,7 @@ export function useProfileFollowMutationQueue(
|
|||
did,
|
||||
followUri: prevFollowingUri,
|
||||
})
|
||||
userActionHistory.unfollow([did])
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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,
|
||||
|
@ -137,14 +120,6 @@ const interstials: Record<
|
|||
key: progressGuideInterstitialType,
|
||||
slot: 0,
|
||||
},
|
||||
{
|
||||
type: feedInterstitialType,
|
||||
params: {
|
||||
variant: 'default',
|
||||
},
|
||||
key: feedInterstitialType,
|
||||
slot: 40,
|
||||
},
|
||||
{
|
||||
type: followInterstitialType,
|
||||
params: {
|
||||
|
|
Loading…
Reference in New Issue