From 3407206f52a03223b9eba925f030cf371833a8ed Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 4 Jul 2024 16:28:38 -0500 Subject: [PATCH] [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 --- src/components/FeedInterstitials.tsx | 105 ++++++++++++++++++++++----- src/state/queries/post-feed.ts | 36 ++++++++- src/state/queries/post.ts | 3 + src/state/queries/profile.ts | 3 + src/state/userActionHistory.ts | 71 ++++++++++++++++++ src/view/com/posts/Feed.tsx | 27 +------ 6 files changed, 196 insertions(+), 49 deletions(-) create mode 100644 src/state/userActionHistory.ts diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index ca3b085b..043a27c2 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -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() 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 ( diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 912548e5..315c9cfa 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -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 } | 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) => { // 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, diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index a511d6b3..071a2e91 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -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 } diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index af00faf2..d9a2c6bb 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -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 } diff --git a/src/state/userActionHistory.ts b/src/state/userActionHistory.ts new file mode 100644 index 00000000..d82b3723 --- /dev/null +++ b/src/state/userActionHistory.ts @@ -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) +} diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 4a9b3729..7623ff37 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -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: {