about summary refs log tree commit diff
path: root/src/state/queries/usePostThread/index.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/queries/usePostThread/index.ts')
-rw-r--r--src/state/queries/usePostThread/index.ts325
1 files changed, 325 insertions, 0 deletions
diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts
new file mode 100644
index 000000000..782888cfb
--- /dev/null
+++ b/src/state/queries/usePostThread/index.ts
@@ -0,0 +1,325 @@
+import {useCallback, useMemo, useState} from 'react'
+import {useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {isWeb} from '#/platform/detection'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
+import {
+  LINEAR_VIEW_BELOW,
+  LINEAR_VIEW_BF,
+  TREE_VIEW_BELOW,
+  TREE_VIEW_BELOW_DESKTOP,
+  TREE_VIEW_BF,
+} from '#/state/queries/usePostThread/const'
+import {
+  createCacheMutator,
+  getThreadPlaceholder,
+} from '#/state/queries/usePostThread/queryCache'
+import {
+  buildThread,
+  sortAndAnnotateThreadItems,
+} from '#/state/queries/usePostThread/traversal'
+import {
+  createPostThreadOtherQueryKey,
+  createPostThreadQueryKey,
+  type ThreadItem,
+  type UsePostThreadQueryResult,
+} from '#/state/queries/usePostThread/types'
+import {getThreadgateRecord} from '#/state/queries/usePostThread/utils'
+import * as views from '#/state/queries/usePostThread/views'
+import {useAgent, useSession} from '#/state/session'
+import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {useBreakpoints} from '#/alf'
+
+export * from '#/state/queries/usePostThread/types'
+
+export function usePostThread({anchor}: {anchor?: string}) {
+  const qc = useQueryClient()
+  const agent = useAgent()
+  const {hasSession} = useSession()
+  const {gtPhone} = useBreakpoints()
+  const moderationOpts = useModerationOpts()
+  const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies()
+  const {
+    isLoaded: isThreadPreferencesLoaded,
+    sort,
+    setSort: baseSetSort,
+    view,
+    setView: baseSetView,
+    prioritizeFollowedUsers,
+  } = useThreadPreferences()
+  const below = useMemo(() => {
+    return view === 'linear'
+      ? LINEAR_VIEW_BELOW
+      : isWeb && gtPhone
+      ? TREE_VIEW_BELOW_DESKTOP
+      : TREE_VIEW_BELOW
+  }, [view, gtPhone])
+
+  const postThreadQueryKey = createPostThreadQueryKey({
+    anchor,
+    sort,
+    view,
+    prioritizeFollowedUsers,
+  })
+  const postThreadOtherQueryKey = createPostThreadOtherQueryKey({
+    anchor,
+    prioritizeFollowedUsers,
+  })
+
+  const query = useQuery<UsePostThreadQueryResult>({
+    enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts,
+    queryKey: postThreadQueryKey,
+    async queryFn(ctx) {
+      const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
+        anchor: anchor!,
+        branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF,
+        below,
+        sort: sort,
+        prioritizeFollowedUsers: prioritizeFollowedUsers,
+      })
+
+      /*
+       * Initialize `ctx.meta` to track if we know we have additional replies
+       * we could fetch once we hit the end.
+       */
+      ctx.meta = ctx.meta || {
+        hasOtherReplies: false,
+      }
+
+      /*
+       * If we know we have additional replies, we'll set this to true.
+       */
+      if (data.hasOtherReplies) {
+        ctx.meta.hasOtherReplies = true
+      }
+
+      const result = {
+        thread: data.thread || [],
+        threadgate: data.threadgate,
+        hasOtherReplies: !!ctx.meta.hasOtherReplies,
+      }
+
+      const record = getThreadgateRecord(result.threadgate)
+      if (result.threadgate && record) {
+        result.threadgate.record = record
+      }
+
+      return result as UsePostThreadQueryResult
+    },
+    placeholderData() {
+      if (!anchor) return
+      const placeholder = getThreadPlaceholder(qc, anchor)
+      /*
+       * Always return something here, even empty data, so that
+       * `isPlaceholderData` is always true, which we'll use to insert
+       * skeletons.
+       */
+      const thread = placeholder ? [placeholder] : []
+      return {thread, threadgate: undefined, hasOtherReplies: false}
+    },
+    select(data) {
+      const record = getThreadgateRecord(data.threadgate)
+      if (data.threadgate && record) {
+        data.threadgate.record = record
+      }
+      return data
+    },
+  })
+
+  const thread = useMemo(() => query.data?.thread || [], [query.data?.thread])
+  const threadgate = useMemo(
+    () => query.data?.threadgate,
+    [query.data?.threadgate],
+  )
+  const hasOtherThreadItems = useMemo(
+    () => !!query.data?.hasOtherReplies,
+    [query.data?.hasOtherReplies],
+  )
+  const [otherItemsVisible, setOtherItemsVisible] = useState(false)
+
+  /**
+   * Creates a mutator for the post thread cache. This is used to insert
+   * replies into the thread cache after posting.
+   */
+  const mutator = useMemo(
+    () =>
+      createCacheMutator({
+        params: {view, below},
+        postThreadQueryKey,
+        postThreadOtherQueryKey,
+        queryClient: qc,
+      }),
+    [qc, view, below, postThreadQueryKey, postThreadOtherQueryKey],
+  )
+
+  /**
+   * If we have additional items available from the server and the user has
+   * chosen to view them, start loading data
+   */
+  const additionalQueryEnabled = hasOtherThreadItems && otherItemsVisible
+  const additionalItemsQuery = useQuery({
+    enabled: additionalQueryEnabled,
+    queryKey: postThreadOtherQueryKey,
+    async queryFn() {
+      const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({
+        anchor: anchor!,
+        prioritizeFollowedUsers,
+      })
+      return data
+    },
+  })
+  const serverOtherThreadItems: ThreadItem[] = useMemo(() => {
+    if (!additionalQueryEnabled) return []
+    if (additionalItemsQuery.isLoading) {
+      return Array.from({length: 2}).map((_, i) =>
+        views.skeleton({
+          key: `other-reply-${i}`,
+          item: 'reply',
+        }),
+      )
+    } else if (additionalItemsQuery.isError) {
+      /*
+       * We could insert an special error component in here, but since these
+       * are optional additional replies, it's not critical that they're shown
+       * atm.
+       */
+      return []
+    } else if (additionalItemsQuery.data?.thread) {
+      const {threadItems} = sortAndAnnotateThreadItems(
+        additionalItemsQuery.data.thread,
+        {
+          view,
+          skipModerationHandling: true,
+          threadgateHiddenReplies: mergeThreadgateHiddenReplies(
+            threadgate?.record,
+          ),
+          moderationOpts: moderationOpts!,
+        },
+      )
+      return threadItems
+    } else {
+      return []
+    }
+  }, [
+    view,
+    additionalQueryEnabled,
+    additionalItemsQuery,
+    mergeThreadgateHiddenReplies,
+    moderationOpts,
+    threadgate?.record,
+  ])
+
+  /**
+   * Sets the sort order for the thread and resets the additional thread items
+   */
+  const setSort: typeof baseSetSort = useCallback(
+    nextSort => {
+      setOtherItemsVisible(false)
+      baseSetSort(nextSort)
+    },
+    [baseSetSort, setOtherItemsVisible],
+  )
+
+  /**
+   * Sets the view variant for the thread and resets the additional thread items
+   */
+  const setView: typeof baseSetView = useCallback(
+    nextView => {
+      setOtherItemsVisible(false)
+      baseSetView(nextView)
+    },
+    [baseSetView, setOtherItemsVisible],
+  )
+
+  /*
+   * This is the main thread response, sorted into separate buckets based on
+   * moderation, and annotated with all UI state needed for rendering.
+   */
+  const {threadItems, otherThreadItems} = useMemo(() => {
+    return sortAndAnnotateThreadItems(thread, {
+      view: view,
+      threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record),
+      moderationOpts: moderationOpts!,
+    })
+  }, [
+    thread,
+    threadgate?.record,
+    mergeThreadgateHiddenReplies,
+    moderationOpts,
+    view,
+  ])
+
+  /*
+   * Take all three sets of thread items and combine them into a single thread,
+   * along with any other thread items required for rendering e.g. "Show more
+   * replies" or the reply composer.
+   */
+  const items = useMemo(() => {
+    return buildThread({
+      threadItems,
+      otherThreadItems,
+      serverOtherThreadItems,
+      isLoading: query.isPlaceholderData,
+      hasSession,
+      hasOtherThreadItems,
+      otherItemsVisible,
+      showOtherItems: () => setOtherItemsVisible(true),
+    })
+  }, [
+    threadItems,
+    otherThreadItems,
+    serverOtherThreadItems,
+    query.isPlaceholderData,
+    hasSession,
+    hasOtherThreadItems,
+    otherItemsVisible,
+    setOtherItemsVisible,
+  ])
+
+  return useMemo(
+    () => ({
+      state: {
+        /*
+         * Copy in any query state that is useful
+         */
+        isFetching: query.isFetching,
+        isPlaceholderData: query.isPlaceholderData,
+        error: query.error,
+        /*
+         * Other state
+         */
+        sort,
+        view,
+        otherItemsVisible,
+      },
+      data: {
+        items,
+        threadgate,
+      },
+      actions: {
+        /*
+         * Copy in any query actions that are useful
+         */
+        insertReplies: mutator.insertReplies,
+        refetch: query.refetch,
+        /*
+         * Other actions
+         */
+        setSort,
+        setView,
+      },
+    }),
+    [
+      query,
+      mutator.insertReplies,
+      otherItemsVisible,
+      sort,
+      view,
+      setSort,
+      setView,
+      threadgate,
+      items,
+    ],
+  )
+}