about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/components/FeedInterstitials.tsx105
-rw-r--r--src/state/queries/post-feed.ts36
-rw-r--r--src/state/queries/post.ts3
-rw-r--r--src/state/queries/profile.ts3
-rw-r--r--src/state/userActionHistory.ts71
-rw-r--r--src/view/com/posts/Feed.tsx27
6 files changed, 196 insertions, 49 deletions
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]}>
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 912548e51..315c9cfad 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<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,
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index a511d6b3d..071a2e91f 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 af00faf27..d9a2c6bbb 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 000000000..d82b3723a
--- /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 4a9b37291..7623ff37e 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,
@@ -138,14 +121,6 @@ const interstials: Record<
       slot: 0,
     },
     {
-      type: feedInterstitialType,
-      params: {
-        variant: 'default',
-      },
-      key: feedInterstitialType,
-      slot: 40,
-    },
-    {
       type: followInterstitialType,
       params: {
         variant: 'default',