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.ts303
1 files changed, 303 insertions, 0 deletions
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
new file mode 100644
index 000000000..7589aa346
--- /dev/null
+++ b/src/state/queries/post-feed.ts
@@ -0,0 +1,303 @@
+import {useCallback} from 'react'
+import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api'
+import {
+  useInfiniteQuery,
+  InfiniteData,
+  QueryKey,
+  QueryClient,
+  useQueryClient,
+} from '@tanstack/react-query'
+import {useFeedTuners} from '../preferences/feed-tuners'
+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'
+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 {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 =
+  | 'posts_with_replies'
+  | 'posts_no_replies'
+  | 'posts_with_media'
+type FeedUri = string
+type ListUri = string
+export type FeedDescriptor =
+  | 'home'
+  | 'following'
+  | `author|${ActorDid}|${AuthorFilter}`
+  | `feedgen|${FeedUri}`
+  | `likes|${ActorDid}`
+  | `list|${ListUri}`
+export interface FeedParams {
+  disableTuner?: boolean
+  mergeFeedEnabled?: boolean
+  mergeFeedSources?: string[]
+}
+
+type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
+
+export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
+  return ['post-feed', feedDesc, params || {}]
+}
+
+export interface FeedPostSliceItem {
+  _reactKey: string
+  uri: string
+  post: AppBskyFeedDefs.PostView
+  record: AppBskyFeedPost.Record
+  reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
+}
+
+export interface FeedPostSlice {
+  _reactKey: string
+  rootUri: string
+  isThread: boolean
+  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[]
+}
+
+export function usePostFeedQuery(
+  feedDesc: FeedDescriptor,
+  params?: FeedParams,
+  opts?: {enabled?: boolean},
+) {
+  const queryClient = useQueryClient()
+  const feedTuners = useFeedTuners(feedDesc)
+  const enabled = opts?.enabled !== false
+
+  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})
+
+      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
+
+      /*
+       * 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,
+        feed: res.feed,
+      }
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage =>
+      lastPage.cursor
+        ? {
+            api: lastPage.api,
+            cursor: lastPage.cursor,
+          }
+        : undefined,
+    select: useCallback(
+      (data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
+        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[],
+            })),
+          })),
+        }
+      },
+      [feedTuners, params?.disableTuner],
+    ),
+  })
+}
+
+export async function pollLatest(page: FeedPage | undefined) {
+  if (!page) {
+    return false
+  }
+
+  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()
+  }
+}
+
+/**
+ * This helper is used by the post-thread placeholder function to
+ * find a post in the query-data cache
+ */
+export function findPostInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): AppBskyFeedDefs.PostView | undefined {
+  const generator = findAllPostsInQueryData(queryClient, uri)
+  const result = generator.next()
+  if (result.done) {
+    return undefined
+  } else {
+    return result.value
+  }
+}
+
+export function* findAllPostsInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): Generator<AppBskyFeedDefs.PostView, void> {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<FeedPageUnselected>
+  >({
+    queryKey: ['post-feed'],
+  })
+  for (const [_queryKey, queryData] of queryDatas) {
+    if (!queryData?.pages) {
+      continue
+    }
+    for (const page of queryData?.pages) {
+      for (const item of page.feed) {
+        if (item.post.uri === uri) {
+          yield item.post
+        }
+        if (
+          AppBskyFeedDefs.isPostView(item.reply?.parent) &&
+          item.reply?.parent?.uri === uri
+        ) {
+          yield item.reply.parent
+        }
+        if (
+          AppBskyFeedDefs.isPostView(item.reply?.root) &&
+          item.reply?.root?.uri === uri
+        ) {
+          yield item.reply.root
+        }
+      }
+    }
+  }
+}
+
+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)
+  }
+}