[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 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]}>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'
|
| '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: {
|
||||||
|
|
Loading…
Reference in New Issue