about summary refs log tree commit diff
path: root/src/state/queries/threadgate/index.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/queries/threadgate/index.ts')
-rw-r--r--src/state/queries/threadgate/index.ts358
1 files changed, 358 insertions, 0 deletions
diff --git a/src/state/queries/threadgate/index.ts b/src/state/queries/threadgate/index.ts
new file mode 100644
index 000000000..a88197cd5
--- /dev/null
+++ b/src/state/queries/threadgate/index.ts
@@ -0,0 +1,358 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedGetPostThread,
+  AppBskyFeedThreadgate,
+  AtUri,
+  BskyAgent,
+} from '@atproto/api'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {networkRetry, retry} from '#/lib/async/retry'
+import {until} from '#/lib/async/until'
+import {STALE} from '#/state/queries'
+import {RQKEY_ROOT as postThreadQueryKeyRoot} from '#/state/queries/post-thread'
+import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
+import {
+  createThreadgateRecord,
+  mergeThreadgateRecords,
+  threadgateAllowUISettingToAllowRecordValue,
+  threadgateViewToAllowUISetting,
+} from '#/state/queries/threadgate/util'
+import {useAgent} from '#/state/session'
+import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies'
+
+export * from '#/state/queries/threadgate/types'
+export * from '#/state/queries/threadgate/util'
+
+export const threadgateRecordQueryKeyRoot = 'threadgate-record'
+export const createThreadgateRecordQueryKey = (uri: string) => [
+  threadgateRecordQueryKeyRoot,
+  uri,
+]
+
+export function useThreadgateRecordQuery({
+  enabled,
+  postUri,
+  initialData,
+}: {
+  enabled?: boolean
+  postUri?: string
+  initialData?: AppBskyFeedThreadgate.Record
+} = {}) {
+  const agent = useAgent()
+
+  return useQuery({
+    enabled: enabled ?? !!postUri,
+    queryKey: createThreadgateRecordQueryKey(postUri || ''),
+    placeholderData: initialData,
+    staleTime: STALE.MINUTES.ONE,
+    async queryFn() {
+      return getThreadgateRecord({
+        agent,
+        postUri: postUri!,
+      })
+    },
+  })
+}
+
+export const threadgateViewQueryKeyRoot = 'threadgate-view'
+export const createThreadgateViewQueryKey = (uri: string) => [
+  threadgateViewQueryKeyRoot,
+  uri,
+]
+export function useThreadgateViewQuery({
+  postUri,
+  initialData,
+}: {
+  postUri?: string
+  initialData?: AppBskyFeedDefs.ThreadgateView
+} = {}) {
+  const agent = useAgent()
+
+  return useQuery({
+    enabled: !!postUri,
+    queryKey: createThreadgateViewQueryKey(postUri || ''),
+    placeholderData: initialData,
+    staleTime: STALE.MINUTES.ONE,
+    async queryFn() {
+      return getThreadgateView({
+        agent,
+        postUri: postUri!,
+      })
+    },
+  })
+}
+
+export async function getThreadgateView({
+  agent,
+  postUri,
+}: {
+  agent: BskyAgent
+  postUri: string
+}) {
+  const {data} = await agent.app.bsky.feed.getPostThread({
+    uri: postUri!,
+    depth: 0,
+  })
+
+  if (AppBskyFeedDefs.isThreadViewPost(data.thread)) {
+    return data.thread.post.threadgate ?? null
+  }
+
+  return null
+}
+
+export async function getThreadgateRecord({
+  agent,
+  postUri,
+}: {
+  agent: BskyAgent
+  postUri: string
+}): Promise<AppBskyFeedThreadgate.Record | null> {
+  const urip = new AtUri(postUri)
+
+  if (!urip.host.startsWith('did:')) {
+    const res = await agent.resolveHandle({
+      handle: urip.host,
+    })
+    urip.host = res.data.did
+  }
+
+  try {
+    const {data} = await retry(
+      2,
+      e => {
+        /*
+         * If the record doesn't exist, we want to return null instead of
+         * throwing an error. NB: This will also catch reference errors, such as
+         * a typo in the URI.
+         */
+        if (e.message.includes(`Could not locate record:`)) {
+          return false
+        }
+        return true
+      },
+      () =>
+        agent.api.com.atproto.repo.getRecord({
+          repo: urip.host,
+          collection: 'app.bsky.feed.threadgate',
+          rkey: urip.rkey,
+        }),
+    )
+
+    if (data.value && AppBskyFeedThreadgate.isRecord(data.value)) {
+      return data.value
+    } else {
+      return null
+    }
+  } catch (e: any) {
+    /*
+     * If the record doesn't exist, we want to return null instead of
+     * throwing an error. NB: This will also catch reference errors, such as
+     * a typo in the URI.
+     */
+    if (e.message.includes(`Could not locate record:`)) {
+      return null
+    } else {
+      throw e
+    }
+  }
+}
+
+export async function writeThreadgateRecord({
+  agent,
+  postUri,
+  threadgate,
+}: {
+  agent: BskyAgent
+  postUri: string
+  threadgate: AppBskyFeedThreadgate.Record
+}) {
+  const postUrip = new AtUri(postUri)
+  const record = createThreadgateRecord({
+    post: postUri,
+    allow: threadgate.allow, // can/should be undefined!
+    hiddenReplies: threadgate.hiddenReplies || [],
+  })
+
+  await networkRetry(2, () =>
+    agent.api.com.atproto.repo.putRecord({
+      repo: agent.session!.did,
+      collection: 'app.bsky.feed.threadgate',
+      rkey: postUrip.rkey,
+      record,
+    }),
+  )
+}
+
+export async function upsertThreadgate(
+  {
+    agent,
+    postUri,
+  }: {
+    agent: BskyAgent
+    postUri: string
+  },
+  callback: (
+    threadgate: AppBskyFeedThreadgate.Record | null,
+  ) => Promise<AppBskyFeedThreadgate.Record | undefined>,
+) {
+  const prev = await getThreadgateRecord({
+    agent,
+    postUri,
+  })
+  const next = await callback(prev)
+  if (!next) return
+  await writeThreadgateRecord({
+    agent,
+    postUri,
+    threadgate: next,
+  })
+}
+
+/**
+ * Update the allow list for a threadgate record.
+ */
+export async function updateThreadgateAllow({
+  agent,
+  postUri,
+  allow,
+}: {
+  agent: BskyAgent
+  postUri: string
+  allow: ThreadgateAllowUISetting[]
+}) {
+  return upsertThreadgate({agent, postUri}, async prev => {
+    if (prev) {
+      return {
+        ...prev,
+        allow: threadgateAllowUISettingToAllowRecordValue(allow),
+      }
+    } else {
+      return createThreadgateRecord({
+        post: postUri,
+        allow: threadgateAllowUISettingToAllowRecordValue(allow),
+      })
+    }
+  })
+}
+
+export function useSetThreadgateAllowMutation() {
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async ({
+      postUri,
+      allow,
+    }: {
+      postUri: string
+      allow: ThreadgateAllowUISetting[]
+    }) => {
+      return upsertThreadgate({agent, postUri}, async prev => {
+        if (prev) {
+          return {
+            ...prev,
+            allow: threadgateAllowUISettingToAllowRecordValue(allow),
+          }
+        } else {
+          return createThreadgateRecord({
+            post: postUri,
+            allow: threadgateAllowUISettingToAllowRecordValue(allow),
+          })
+        }
+      })
+    },
+    async onSuccess(_, {postUri, allow}) {
+      await until(
+        5, // 5 tries
+        1e3, // 1s delay between tries
+        (res: AppBskyFeedGetPostThread.Response) => {
+          const thread = res.data.thread
+          if (AppBskyFeedDefs.isThreadViewPost(thread)) {
+            const fetchedSettings = threadgateViewToAllowUISetting(
+              thread.post.threadgate,
+            )
+            return JSON.stringify(fetchedSettings) === JSON.stringify(allow)
+          }
+          return false
+        },
+        () => {
+          return agent.app.bsky.feed.getPostThread({
+            uri: postUri,
+            depth: 0,
+          })
+        },
+      )
+
+      queryClient.invalidateQueries({
+        queryKey: [postThreadQueryKeyRoot],
+      })
+      queryClient.invalidateQueries({
+        queryKey: [threadgateRecordQueryKeyRoot],
+      })
+      queryClient.invalidateQueries({
+        queryKey: [threadgateViewQueryKeyRoot],
+      })
+    },
+  })
+}
+
+export function useToggleReplyVisibilityMutation() {
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+  const hiddenReplies = useThreadgateHiddenReplyUrisAPI()
+
+  return useMutation({
+    mutationFn: async ({
+      postUri,
+      replyUri,
+      action,
+    }: {
+      postUri: string
+      replyUri: string
+      action: 'hide' | 'show'
+    }) => {
+      if (action === 'hide') {
+        hiddenReplies.addHiddenReplyUri(replyUri)
+      } else if (action === 'show') {
+        hiddenReplies.removeHiddenReplyUri(replyUri)
+      }
+
+      await upsertThreadgate({agent, postUri}, async prev => {
+        if (prev) {
+          if (action === 'hide') {
+            return mergeThreadgateRecords(prev, {
+              hiddenReplies: [replyUri],
+            })
+          } else if (action === 'show') {
+            return {
+              ...prev,
+              hiddenReplies:
+                prev.hiddenReplies?.filter(uri => uri !== replyUri) || [],
+            }
+          }
+        } else {
+          if (action === 'hide') {
+            return createThreadgateRecord({
+              post: postUri,
+              hiddenReplies: [replyUri],
+            })
+          }
+        }
+      })
+    },
+    onSuccess() {
+      queryClient.invalidateQueries({
+        queryKey: [threadgateRecordQueryKeyRoot],
+      })
+    },
+    onError(_, {replyUri, action}) {
+      if (action === 'hide') {
+        hiddenReplies.removeHiddenReplyUri(replyUri)
+      } else if (action === 'show') {
+        hiddenReplies.addHiddenReplyUri(replyUri)
+      }
+    },
+  })
+}