From 3407206f52a03223b9eba925f030cf371833a8ed Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Thu, 4 Jul 2024 16:28:38 -0500
Subject: [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>
---
 src/components/FeedInterstitials.tsx | 105 ++++++++++++++++++++++++++++-------
 1 file changed, 85 insertions(+), 20 deletions(-)

(limited to 'src/components/FeedInterstitials.tsx')

diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index ca3b085b9..043a27c29 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<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]}>
-- 
cgit 1.4.1

