about summary refs log tree commit diff
path: root/src/state/queries/post-feed.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/queries/post-feed.ts')
-rw-r--r--src/state/queries/post-feed.ts261
1 files changed, 157 insertions, 104 deletions
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 113e6f2fb..7cf315ef6 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -1,4 +1,3 @@
-import {useCallback, useMemo} from 'react'
 import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
 import {
   useInfiniteQuery,
@@ -7,9 +6,8 @@ import {
   QueryClient,
   useQueryClient,
 } from '@tanstack/react-query'
-import {getAgent} from '../session'
 import {useFeedTuners} from '../preferences/feed-tuners'
-import {FeedTuner, NoopFeedTuner} from 'lib/api/feed-manip'
+import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
 import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
 import {FollowingFeedAPI} from 'lib/api/feed/following'
 import {AuthorFeedAPI} from 'lib/api/feed/author'
@@ -17,10 +15,13 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
 import {CustomFeedAPI} from 'lib/api/feed/custom'
 import {ListFeedAPI} from 'lib/api/feed/list'
 import {MergeFeedAPI} from 'lib/api/feed/merge'
-import {useModerationOpts} from '#/state/queries/preferences'
 import {logger} from '#/logger'
 import {STALE} from '#/state/queries'
 import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri'
+import {getAgent} from '#/state/session'
+import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
+import {getModerationOpts} from '#/state/queries/preferences/moderation'
+import {KnownError} from '#/view/com/posts/FeedErrorMessage'
 
 type ActorDid = string
 type AuthorFilter =
@@ -42,7 +43,7 @@ export interface FeedParams {
   mergeFeedSources?: string[]
 }
 
-type RQPageParam = string | undefined
+type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
 
 export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
   return ['post-feed', feedDesc, params || {}]
@@ -63,7 +64,15 @@ export interface FeedPostSlice {
   items: FeedPostSliceItem[]
 }
 
+export interface FeedPageUnselected {
+  api: FeedAPI
+  cursor: string | undefined
+  feed: AppBskyFeedDefs.FeedViewPost[]
+}
+
 export interface FeedPage {
+  api: FeedAPI
+  tuner: FeedTuner | NoopFeedTuner
   cursor: string | undefined
   slices: FeedPostSlice[]
 }
@@ -76,117 +85,139 @@ export function usePostFeedQuery(
   const queryClient = useQueryClient()
   const feedTuners = useFeedTuners(feedDesc)
   const enabled = opts?.enabled !== false
-  const moderationOpts = useModerationOpts()
-  const agent = getAgent()
-
-  const api: FeedAPI = useMemo(() => {
-    if (feedDesc === 'home') {
-      return new MergeFeedAPI(agent, params || {}, feedTuners)
-    } else if (feedDesc === 'following') {
-      return new FollowingFeedAPI(agent)
-    } else if (feedDesc.startsWith('author')) {
-      const [_, actor, filter] = feedDesc.split('|')
-      return new AuthorFeedAPI(agent, {actor, filter})
-    } else if (feedDesc.startsWith('likes')) {
-      const [_, actor] = feedDesc.split('|')
-      return new LikesFeedAPI(agent, {actor})
-    } else if (feedDesc.startsWith('feedgen')) {
-      const [_, feed] = feedDesc.split('|')
-      return new CustomFeedAPI(agent, {feed})
-    } else if (feedDesc.startsWith('list')) {
-      const [_, list] = feedDesc.split('|')
-      return new ListFeedAPI(agent, {list})
-    } else {
-      // shouldnt happen
-      return new FollowingFeedAPI(agent)
-    }
-  }, [feedDesc, params, feedTuners, agent])
-
-  const disableTuner = !!params?.disableTuner
-  const tuner = useMemo(
-    () => (disableTuner ? new NoopFeedTuner() : new FeedTuner()),
-    [disableTuner],
-  )
-
-  const pollLatest = useCallback(async () => {
-    if (!enabled) {
-      return false
-    }
-
-    logger.debug('usePostFeedQuery: pollLatest')
-
-    const post = await api.peekLatest()
-
-    if (post && moderationOpts) {
-      const slices = tuner.tune([post], feedTuners, {
-        dryRun: true,
-        maintainOrder: true,
-      })
-      if (slices[0]) {
-        if (
-          !moderatePost(slices[0].items[0].post, moderationOpts).content.filter
-        ) {
-          return true
-        }
-      }
-    }
 
-    return false
-  }, [api, tuner, feedTuners, moderationOpts, enabled])
-
-  const out = useInfiniteQuery<
-    FeedPage,
+  return useInfiniteQuery<
+    FeedPageUnselected,
     Error,
     InfiniteData<FeedPage>,
     QueryKey,
     RQPageParam
   >({
+    enabled,
     staleTime: STALE.INFINITY,
     queryKey: RQKEY(feedDesc, params),
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       logger.debug('usePostFeedQuery', {feedDesc, pageParam})
-      if (!pageParam) {
-        tuner.reset()
-      }
-      const res = await api.fetch({cursor: pageParam, limit: 30})
+
+      const {api, cursor} = pageParam
+        ? pageParam
+        : {
+            api: createApi(feedDesc, params || {}, feedTuners),
+            cursor: undefined,
+          }
+
+      const res = await api.fetch({cursor, limit: 30})
       precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution
-      const slices = tuner.tune(res.feed, feedTuners)
+
+      /*
+       * If this is a public view, we need to check if posts fail moderation.
+       * If all fail, we throw an error. If only some fail, we continue and let
+       * moderations happen later, which results in some posts being shown and
+       * some not.
+       */
+      if (!getAgent().session) {
+        assertSomePostsPassModeration(res.feed)
+      }
+
       return {
+        api,
         cursor: res.cursor,
-        slices: slices.map(slice => ({
-          _reactKey: slice._reactKey,
-          rootUri: slice.rootItem.post.uri,
-          isThread:
-            slice.items.length > 1 &&
-            slice.items.every(
-              item => item.post.author.did === slice.items[0].post.author.did,
-            ),
-          items: slice.items
-            .map((item, i) => {
-              if (
-                AppBskyFeedPost.isRecord(item.post.record) &&
-                AppBskyFeedPost.validateRecord(item.post.record).success
-              ) {
-                return {
-                  _reactKey: `${slice._reactKey}-${i}`,
-                  uri: item.post.uri,
-                  post: item.post,
-                  record: item.post.record,
-                  reason: i === 0 && slice.source ? slice.source : item.reason,
+        feed: res.feed,
+      }
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => ({
+      api: lastPage.api,
+      cursor: lastPage.cursor,
+    }),
+    select(data) {
+      const tuner = params?.disableTuner
+        ? new NoopFeedTuner()
+        : new FeedTuner(feedTuners)
+      return {
+        pageParams: data.pageParams,
+        pages: data.pages.map(page => ({
+          api: page.api,
+          tuner,
+          cursor: page.cursor,
+          slices: tuner.tune(page.feed).map(slice => ({
+            _reactKey: slice._reactKey,
+            rootUri: slice.rootItem.post.uri,
+            isThread:
+              slice.items.length > 1 &&
+              slice.items.every(
+                item => item.post.author.did === slice.items[0].post.author.did,
+              ),
+            items: slice.items
+              .map((item, i) => {
+                if (
+                  AppBskyFeedPost.isRecord(item.post.record) &&
+                  AppBskyFeedPost.validateRecord(item.post.record).success
+                ) {
+                  return {
+                    _reactKey: `${slice._reactKey}-${i}`,
+                    uri: item.post.uri,
+                    post: item.post,
+                    record: item.post.record,
+                    reason:
+                      i === 0 && slice.source ? slice.source : item.reason,
+                  }
                 }
-              }
-              return undefined
-            })
-            .filter(Boolean) as FeedPostSliceItem[],
+                return undefined
+              })
+              .filter(Boolean) as FeedPostSliceItem[],
+          })),
         })),
       }
     },
-    initialPageParam: undefined,
-    getNextPageParam: lastPage => lastPage.cursor,
-    enabled,
   })
+}
+
+export async function pollLatest(page: FeedPage | undefined) {
+  if (!page) {
+    return false
+  }
 
-  return {...out, pollLatest}
+  logger.debug('usePostFeedQuery: pollLatest')
+  const post = await page.api.peekLatest()
+  if (post) {
+    const slices = page.tuner.tune([post], {
+      dryRun: true,
+      maintainOrder: true,
+    })
+    if (slices[0]) {
+      return true
+    }
+  }
+
+  return false
+}
+
+function createApi(
+  feedDesc: FeedDescriptor,
+  params: FeedParams,
+  feedTuners: FeedTunerFn[],
+) {
+  if (feedDesc === 'home') {
+    return new MergeFeedAPI(params, feedTuners)
+  } else if (feedDesc === 'following') {
+    return new FollowingFeedAPI()
+  } else if (feedDesc.startsWith('author')) {
+    const [_, actor, filter] = feedDesc.split('|')
+    return new AuthorFeedAPI({actor, filter})
+  } else if (feedDesc.startsWith('likes')) {
+    const [_, actor] = feedDesc.split('|')
+    return new LikesFeedAPI({actor})
+  } else if (feedDesc.startsWith('feedgen')) {
+    const [_, feed] = feedDesc.split('|')
+    return new CustomFeedAPI({feed})
+  } else if (feedDesc.startsWith('list')) {
+    const [_, list] = feedDesc.split('|')
+    return new ListFeedAPI({list})
+  } else {
+    // shouldnt happen
+    return new FollowingFeedAPI()
+  }
 }
 
 /**
@@ -196,8 +227,10 @@ export function usePostFeedQuery(
 export function findPostInQueryData(
   queryClient: QueryClient,
   uri: string,
-): FeedPostSliceItem | undefined {
-  const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
+): AppBskyFeedDefs.FeedViewPost | undefined {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<FeedPageUnselected>
+  >({
     queryKey: ['post-feed'],
   })
   for (const [_queryKey, queryData] of queryDatas) {
@@ -205,14 +238,34 @@ export function findPostInQueryData(
       continue
     }
     for (const page of queryData?.pages) {
-      for (const slice of page.slices) {
-        for (const item of slice.items) {
-          if (item.uri === uri) {
-            return item
-          }
+      for (const item of page.feed) {
+        if (item.post.uri === uri) {
+          return item
         }
       }
     }
   }
   return undefined
 }
+
+function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
+  // assume false
+  let somePostsPassModeration = false
+
+  for (const item of feed) {
+    const moderationOpts = getModerationOpts({
+      userDid: '',
+      preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
+    })
+    const moderation = moderatePost(item.post, moderationOpts)
+
+    if (!moderation.content.filter) {
+      // we have a sfw post
+      somePostsPassModeration = true
+    }
+  }
+
+  if (!somePostsPassModeration) {
+    throw new Error(KnownError.FeedNSFPublic)
+  }
+}