mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[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>

authored by

Eric Bailey
Dan Abramov
and committed by
GitHub
3407206f 1c6bfc02

+196 -49
+85 -20
src/components/FeedInterstitials.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 3 import {ScrollView} from 'react-native-gesture-handler' 4 - import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' 4 + import {AppBskyFeedDefs, AtUri} from '@atproto/api' 5 5 import {msg, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 import {useNavigation} from '@react-navigation/native' ··· 9 9 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 10 10 import {NavigationProp} from '#/lib/routes/types' 11 11 import {logEvent} from '#/lib/statsig/statsig' 12 + import {logger} from '#/logger' 12 13 import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 14 import {useGetPopularFeedsQuery} from '#/state/queries/feed' 14 - import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' 15 + import {useProfilesQuery} from '#/state/queries/profile' 15 16 import {useProgressGuide} from '#/state/shell/progress-guide' 17 + import * as userActionHistory from '#/state/userActionHistory' 18 + import {SeenPost} from '#/state/userActionHistory' 16 19 import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf' 17 20 import {Button} from '#/components/Button' 18 21 import * as FeedCard from '#/components/FeedCard' ··· 80 83 ) 81 84 } 82 85 86 + function getRank(seenPost: SeenPost): string { 87 + let tier: string 88 + if (seenPost.feedContext === 'popfriends') { 89 + tier = 'a' 90 + } else if (seenPost.feedContext?.startsWith('cluster')) { 91 + tier = 'b' 92 + } else if (seenPost.feedContext?.startsWith('ntpc')) { 93 + tier = 'c' 94 + } else if (seenPost.feedContext?.startsWith('t-')) { 95 + tier = 'd' 96 + } else if (seenPost.feedContext === 'nettop') { 97 + tier = 'e' 98 + } else { 99 + tier = 'f' 100 + } 101 + let score = Math.round( 102 + Math.log( 103 + 1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount, 104 + ), 105 + ) 106 + if (seenPost.isFollowedBy || Math.random() > 0.9) { 107 + score *= 2 108 + } 109 + const rank = 100 - score 110 + return `${tier}-${rank}` 111 + } 112 + 113 + function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 { 114 + const rankA = getRank(postA) 115 + const rankB = getRank(postB) 116 + // Yes, we're comparing strings here. 117 + // The "larger" string means a worse rank. 118 + if (rankA > rankB) { 119 + return 1 120 + } else if (rankA < rankB) { 121 + return -1 122 + } else { 123 + return 0 124 + } 125 + } 126 + 127 + function useExperimentalSuggestedUsersQuery() { 128 + const userActionSnapshot = userActionHistory.useActionHistorySnapshot() 129 + const dids = React.useMemo(() => { 130 + const {likes, follows, seen} = userActionSnapshot 131 + const likeDids = likes 132 + .map(l => new AtUri(l)) 133 + .map(uri => uri.host) 134 + .filter(did => !follows.includes(did)) 135 + const seenDids = seen 136 + .sort(sortSeenPosts) 137 + .map(l => new AtUri(l.uri)) 138 + .map(uri => uri.host) 139 + return [...new Set([...likeDids, ...seenDids])] 140 + }, [userActionSnapshot]) 141 + const {data, isLoading, error} = useProfilesQuery({ 142 + handles: dids.slice(0, 16), 143 + }) 144 + 145 + const profiles = data 146 + ? data.profiles.filter(profile => { 147 + return !profile.viewer?.following 148 + }) 149 + : [] 150 + 151 + return { 152 + isLoading, 153 + error, 154 + profiles: profiles.slice(0, 6), 155 + } 156 + } 157 + 83 158 export function SuggestedFollows() { 84 159 const t = useTheme() 85 160 const {_} = useLingui() 86 161 const { 87 162 isLoading: isSuggestionsLoading, 88 - data, 163 + profiles, 89 164 error, 90 - } = useSuggestedFollowsQuery({limit: 6}) 165 + } = useExperimentalSuggestedUsersQuery() 91 166 const moderationOpts = useModerationOpts() 92 167 const navigation = useNavigation<NavigationProp>() 93 168 const {gtMobile} = useBreakpoints() 94 169 const isLoading = isSuggestionsLoading || !moderationOpts 95 170 const maxLength = gtMobile ? 4 : 6 96 - 97 - const profiles: AppBskyActorDefs.ProfileViewBasic[] = [] 98 - if (data) { 99 - // Currently the responses contain duplicate items. 100 - // Needs to be fixed on backend, but let's dedupe to be safe. 101 - let seen = new Set() 102 - for (const page of data.pages) { 103 - for (const actor of page.actors) { 104 - if (!seen.has(actor.did)) { 105 - seen.add(actor.did) 106 - profiles.push(actor) 107 - } 108 - } 109 - } 110 - } 111 171 112 172 const content = isLoading ? ( 113 173 Array(maxLength) ··· 164 224 </> 165 225 ) 166 226 167 - return error ? null : ( 227 + if (error || (!isLoading && profiles.length < 4)) { 228 + logger.debug(`Not enough profiles to show suggested follows`) 229 + return null 230 + } 231 + 232 + return ( 168 233 <View 169 234 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> 170 235 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
+33 -3
src/state/queries/post-feed.ts
··· 17 17 18 18 import {HomeFeedAPI} from '#/lib/api/feed/home' 19 19 import {aggregateUserInterests} from '#/lib/api/feed/utils' 20 + import {DISCOVER_FEED_URI} from '#/lib/constants' 20 21 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 21 22 import {logger} from '#/logger' 22 23 import {STALE} from '#/state/queries' 23 24 import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' 24 25 import {useAgent} from '#/state/session' 26 + import * as userActionHistory from '#/state/userActionHistory' 25 27 import {AuthorFeedAPI} from 'lib/api/feed/author' 26 28 import {CustomFeedAPI} from 'lib/api/feed/custom' 27 29 import {FollowingFeedAPI} from 'lib/api/feed/following' ··· 131 133 result: InfiniteData<FeedPage> 132 134 } | null>(null) 133 135 const lastPageCountRef = useRef(0) 136 + const isDiscover = feedDesc.includes(DISCOVER_FEED_URI) 134 137 135 138 // Make sure this doesn't invalidate unless really needed. 136 139 const selectArgs = React.useMemo( ··· 139 142 disableTuner: params?.disableTuner, 140 143 moderationOpts, 141 144 ignoreFilterFor: opts?.ignoreFilterFor, 145 + isDiscover, 142 146 }), 143 - [feedTuners, params?.disableTuner, moderationOpts, opts?.ignoreFilterFor], 147 + [ 148 + feedTuners, 149 + params?.disableTuner, 150 + moderationOpts, 151 + opts?.ignoreFilterFor, 152 + isDiscover, 153 + ], 144 154 ) 145 155 146 156 const query = useInfiniteQuery< ··· 219 229 (data: InfiniteData<FeedPageUnselected, RQPageParam>) => { 220 230 // If the selection depends on some data, that data should 221 231 // be included in the selectArgs object and read here. 222 - const {feedTuners, disableTuner, moderationOpts, ignoreFilterFor} = 223 - selectArgs 232 + const { 233 + feedTuners, 234 + disableTuner, 235 + moderationOpts, 236 + ignoreFilterFor, 237 + isDiscover, 238 + } = selectArgs 224 239 225 240 const tuner = disableTuner 226 241 ? new NoopFeedTuner() ··· 291 306 ) { 292 307 return undefined 293 308 } 309 + } 310 + 311 + if (isDiscover) { 312 + userActionHistory.seen( 313 + slice.items.map(item => ({ 314 + feedContext: item.feedContext, 315 + likeCount: item.post.likeCount ?? 0, 316 + repostCount: item.post.repostCount ?? 0, 317 + replyCount: item.post.replyCount ?? 0, 318 + isFollowedBy: Boolean( 319 + item.post.author.viewer?.followedBy, 320 + ), 321 + uri: item.post.uri, 322 + })), 323 + ) 294 324 } 295 325 296 326 return {
+3
src/state/queries/post.ts
··· 8 8 import {updatePostShadow} from '#/state/cache/post-shadow' 9 9 import {Shadow} from '#/state/cache/types' 10 10 import {useAgent, useSession} from '#/state/session' 11 + import * as userActionHistory from '#/state/userActionHistory' 11 12 import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes' 12 13 import {findProfileQueryData} from './profile' 13 14 ··· 92 93 uri: postUri, 93 94 cid: postCid, 94 95 }) 96 + userActionHistory.like([postUri]) 95 97 return likeUri 96 98 } else { 97 99 if (prevLikeUri) { ··· 99 101 postUri: postUri, 100 102 likeUri: prevLikeUri, 101 103 }) 104 + userActionHistory.unlike([postUri]) 102 105 } 103 106 return undefined 104 107 }
+3
src/state/queries/profile.ts
··· 23 23 import {Shadow} from '#/state/cache/types' 24 24 import {STALE} from '#/state/queries' 25 25 import {resetProfilePostsQueries} from '#/state/queries/post-feed' 26 + import * as userActionHistory from '#/state/userActionHistory' 26 27 import {updateProfileShadow} from '../cache/profile-shadow' 27 28 import {useAgent, useSession} from '../session' 28 29 import { ··· 233 234 const {uri} = await followMutation.mutateAsync({ 234 235 did, 235 236 }) 237 + userActionHistory.follow([did]) 236 238 return uri 237 239 } else { 238 240 if (prevFollowingUri) { ··· 240 242 did, 241 243 followUri: prevFollowingUri, 242 244 }) 245 + userActionHistory.unfollow([did]) 243 246 } 244 247 return undefined 245 248 }
+71
src/state/userActionHistory.ts
··· 1 + import React from 'react' 2 + 3 + const LIKE_WINDOW = 100 4 + const FOLLOW_WINDOW = 100 5 + const SEEN_WINDOW = 100 6 + 7 + export type SeenPost = { 8 + uri: string 9 + likeCount: number 10 + repostCount: number 11 + replyCount: number 12 + isFollowedBy: boolean 13 + feedContext: string | undefined 14 + } 15 + 16 + export type UserActionHistory = { 17 + /** 18 + * The last 100 post URIs the user has liked 19 + */ 20 + likes: string[] 21 + /** 22 + * The last 100 DIDs the user has followed 23 + */ 24 + follows: string[] 25 + /** 26 + * The last 100 post URIs the user has seen from the Discover feed only 27 + */ 28 + seen: SeenPost[] 29 + } 30 + 31 + const userActionHistory: UserActionHistory = { 32 + likes: [], 33 + follows: [], 34 + seen: [], 35 + } 36 + 37 + export function getActionHistory() { 38 + return userActionHistory 39 + } 40 + 41 + export function useActionHistorySnapshot() { 42 + return React.useState(() => getActionHistory())[0] 43 + } 44 + 45 + export function like(postUris: string[]) { 46 + userActionHistory.likes = userActionHistory.likes 47 + .concat(postUris) 48 + .slice(-LIKE_WINDOW) 49 + } 50 + export function unlike(postUris: string[]) { 51 + userActionHistory.likes = userActionHistory.likes.filter( 52 + uri => !postUris.includes(uri), 53 + ) 54 + } 55 + 56 + export function follow(dids: string[]) { 57 + userActionHistory.follows = userActionHistory.follows 58 + .concat(dids) 59 + .slice(-FOLLOW_WINDOW) 60 + } 61 + export function unfollow(dids: string[]) { 62 + userActionHistory.follows = userActionHistory.follows.filter( 63 + uri => !dids.includes(uri), 64 + ) 65 + } 66 + 67 + export function seen(posts: SeenPost[]) { 68 + userActionHistory.seen = userActionHistory.seen 69 + .concat(posts) 70 + .slice(-SEEN_WINDOW) 71 + }
+1 -26
src/view/com/posts/Feed.tsx
··· 110 110 | 'interstitialProgressGuide' 111 111 })[] 112 112 > = { 113 - following: [ 114 - { 115 - type: followInterstitialType, 116 - params: { 117 - variant: 'default', 118 - }, 119 - key: followInterstitialType, 120 - slot: 20, 121 - }, 122 - { 123 - type: feedInterstitialType, 124 - params: { 125 - variant: 'default', 126 - }, 127 - key: feedInterstitialType, 128 - slot: 40, 129 - }, 130 - ], 113 + following: [], 131 114 discover: [ 132 115 { 133 116 type: progressGuideInterstitialType, ··· 136 119 }, 137 120 key: progressGuideInterstitialType, 138 121 slot: 0, 139 - }, 140 - { 141 - type: feedInterstitialType, 142 - params: { 143 - variant: 'default', 144 - }, 145 - key: feedInterstitialType, 146 - slot: 40, 147 122 }, 148 123 { 149 124 type: followInterstitialType,