about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-rw-r--r--src/state/cache/post-shadow.ts20
-rw-r--r--src/state/queries/notifications/feed.ts55
-rw-r--r--src/state/queries/post-thread.ts18
-rw-r--r--src/state/queries/post.ts24
-rw-r--r--src/state/queries/postgate/index.ts295
-rw-r--r--src/state/queries/postgate/util.ts196
-rw-r--r--src/state/queries/threadgate.ts38
-rw-r--r--src/state/queries/threadgate/index.ts358
-rw-r--r--src/state/queries/threadgate/types.ts6
-rw-r--r--src/state/queries/threadgate/util.ts141
-rw-r--r--src/state/threadgate-hidden-replies.tsx69
11 files changed, 1167 insertions, 53 deletions
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index b37e9bd42..65300a8ef 100644
--- a/src/state/cache/post-shadow.ts
+++ b/src/state/cache/post-shadow.ts
@@ -1,5 +1,9 @@
 import {useEffect, useMemo, useState} from 'react'
-import {AppBskyFeedDefs} from '@atproto/api'
+import {
+  AppBskyEmbedRecord,
+  AppBskyEmbedRecordWithMedia,
+  AppBskyFeedDefs,
+} from '@atproto/api'
 import {QueryClient} from '@tanstack/react-query'
 import EventEmitter from 'eventemitter3'
 
@@ -16,6 +20,7 @@ export interface PostShadow {
   likeUri: string | undefined
   repostUri: string | undefined
   isDeleted: boolean
+  embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
 }
 
 export const POST_TOMBSTONE = Symbol('PostTombstone')
@@ -87,8 +92,21 @@ function mergeShadow(
     repostCount = Math.max(0, repostCount)
   }
 
+  let embed: typeof post.embed
+  if ('embed' in shadow) {
+    if (
+      (AppBskyEmbedRecord.isView(post.embed) &&
+        AppBskyEmbedRecord.isView(shadow.embed)) ||
+      (AppBskyEmbedRecordWithMedia.isView(post.embed) &&
+        AppBskyEmbedRecordWithMedia.isView(shadow.embed))
+    ) {
+      embed = shadow.embed
+    }
+  }
+
   return castAsShadow({
     ...post,
+    embed: embed || post.embed,
     likeCount: likeCount,
     repostCount: repostCount,
     viewer: {
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 997076e81..55e048308 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -16,7 +16,7 @@
  * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
  */
 
-import {useEffect, useRef} from 'react'
+import {useCallback, useEffect, useMemo, useRef} from 'react'
 import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
 import {
   InfiniteData,
@@ -27,6 +27,7 @@ import {
 } from '@tanstack/react-query'
 
 import {useAgent} from '#/state/session'
+import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
 import {useModerationOpts} from '../../preferences/moderation-opts'
 import {STALE} from '..'
 import {
@@ -58,11 +59,18 @@ export function useNotificationFeedQuery(opts?: {
   const moderationOpts = useModerationOpts()
   const unreads = useUnreadNotificationsApi()
   const enabled = opts?.enabled !== false
+  const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris()
 
   // false: force showing all notifications
   // undefined: let the server decide
   const priority = opts?.overridePriorityNotifications ? false : undefined
 
+  const selectArgs = useMemo(() => {
+    return {
+      hiddenReplyUris,
+    }
+  }, [hiddenReplyUris])
+
   const query = useInfiniteQuery<
     FeedPage,
     Error,
@@ -101,20 +109,41 @@ export function useNotificationFeedQuery(opts?: {
     initialPageParam: undefined,
     getNextPageParam: lastPage => lastPage.cursor,
     enabled,
-    select(data: InfiniteData<FeedPage>) {
-      // override 'isRead' using the first page's returned seenAt
-      // we do this because the `markAllRead()` call above will
-      // mark subsequent pages as read prematurely
-      const seenAt = data.pages[0]?.seenAt || new Date()
-      for (const page of data.pages) {
-        for (const item of page.items) {
-          item.notification.isRead =
-            seenAt > new Date(item.notification.indexedAt)
+    select: useCallback(
+      (data: InfiniteData<FeedPage>) => {
+        const {hiddenReplyUris} = selectArgs
+
+        // override 'isRead' using the first page's returned seenAt
+        // we do this because the `markAllRead()` call above will
+        // mark subsequent pages as read prematurely
+        const seenAt = data.pages[0]?.seenAt || new Date()
+        for (const page of data.pages) {
+          for (const item of page.items) {
+            item.notification.isRead =
+              seenAt > new Date(item.notification.indexedAt)
+          }
         }
-      }
 
-      return data
-    },
+        data = {
+          ...data,
+          pages: data.pages.map(page => {
+            return {
+              ...page,
+              items: page.items.filter(item => {
+                const isHiddenReply =
+                  item.type === 'reply' &&
+                  item.subjectUri &&
+                  hiddenReplyUris.has(item.subjectUri)
+                return !isHiddenReply
+              }),
+            }
+          }),
+        }
+
+        return data
+      },
+      [selectArgs],
+    ),
   })
 
   // The server may end up returning an empty page, a page with too few items,
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index fd419d1c4..3370c3617 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -138,6 +138,7 @@ export function sortThread(
   modCache: ThreadModerationCache,
   currentDid: string | undefined,
   justPostedUris: Set<string>,
+  threadgateRecordHiddenReplies: Set<string>,
 ): ThreadNode {
   if (node.type !== 'post') {
     return node
@@ -185,6 +186,14 @@ export function sortThread(
         return 1 // current account's reply
       }
 
+      const aHidden = threadgateRecordHiddenReplies.has(a.uri)
+      const bHidden = threadgateRecordHiddenReplies.has(b.uri)
+      if (aHidden && !aIsBySelf && !bHidden) {
+        return 1
+      } else if (bHidden && !bIsBySelf && !aHidden) {
+        return -1
+      }
+
       const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
       const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
       if (aBlur !== bBlur) {
@@ -222,7 +231,14 @@ export function sortThread(
       return b.post.indexedAt.localeCompare(a.post.indexedAt)
     })
     node.replies.forEach(reply =>
-      sortThread(reply, opts, modCache, currentDid, justPostedUris),
+      sortThread(
+        reply,
+        opts,
+        modCache,
+        currentDid,
+        justPostedUris,
+        threadgateRecordHiddenReplies,
+      ),
     )
   }
   return node
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 071a2e91f..197903bee 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -73,6 +73,30 @@ export function useGetPost() {
   )
 }
 
+export function useGetPosts() {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+  return useCallback(
+    async ({uris}: {uris: string[]}) => {
+      return queryClient.fetchQuery({
+        queryKey: RQKEY(uris.join(',') || ''),
+        async queryFn() {
+          const res = await agent.getPosts({
+            uris,
+          })
+
+          if (res.success) {
+            return res.data.posts
+          } else {
+            throw new Error('useGetPosts failed')
+          }
+        },
+      })
+    },
+    [queryClient, agent],
+  )
+}
+
 export function usePostLikeMutationQueue(
   post: Shadow<AppBskyFeedDefs.PostView>,
   logContext: LogEvents['post:like']['logContext'] &
diff --git a/src/state/queries/postgate/index.ts b/src/state/queries/postgate/index.ts
new file mode 100644
index 000000000..149b9cbe9
--- /dev/null
+++ b/src/state/queries/postgate/index.ts
@@ -0,0 +1,295 @@
+import React from 'react'
+import {
+  AppBskyEmbedRecord,
+  AppBskyEmbedRecordWithMedia,
+  AppBskyFeedDefs,
+  AppBskyFeedPostgate,
+  AtUri,
+  BskyAgent,
+} from '@atproto/api'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {networkRetry, retry} from '#/lib/async/retry'
+import {logger} from '#/logger'
+import {updatePostShadow} from '#/state/cache/post-shadow'
+import {STALE} from '#/state/queries'
+import {useGetPosts} from '#/state/queries/post'
+import {
+  createMaybeDetachedQuoteEmbed,
+  createPostgateRecord,
+  mergePostgateRecords,
+  POSTGATE_COLLECTION,
+} from '#/state/queries/postgate/util'
+import {useAgent} from '#/state/session'
+
+export async function getPostgateRecord({
+  agent,
+  postUri,
+}: {
+  agent: BskyAgent
+  postUri: string
+}): Promise<AppBskyFeedPostgate.Record | undefined> {
+  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: POSTGATE_COLLECTION,
+          rkey: urip.rkey,
+        }),
+    )
+
+    if (data.value && AppBskyFeedPostgate.isRecord(data.value)) {
+      return data.value
+    } else {
+      return undefined
+    }
+  } 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 undefined
+    } else {
+      throw e
+    }
+  }
+}
+
+export async function writePostgateRecord({
+  agent,
+  postUri,
+  postgate,
+}: {
+  agent: BskyAgent
+  postUri: string
+  postgate: AppBskyFeedPostgate.Record
+}) {
+  const postUrip = new AtUri(postUri)
+
+  await networkRetry(2, () =>
+    agent.api.com.atproto.repo.putRecord({
+      repo: agent.session!.did,
+      collection: POSTGATE_COLLECTION,
+      rkey: postUrip.rkey,
+      record: postgate,
+    }),
+  )
+}
+
+export async function upsertPostgate(
+  {
+    agent,
+    postUri,
+  }: {
+    agent: BskyAgent
+    postUri: string
+  },
+  callback: (
+    postgate: AppBskyFeedPostgate.Record | undefined,
+  ) => Promise<AppBskyFeedPostgate.Record | undefined>,
+) {
+  const prev = await getPostgateRecord({
+    agent,
+    postUri,
+  })
+  const next = await callback(prev)
+  if (!next) return
+  await writePostgateRecord({
+    agent,
+    postUri,
+    postgate: next,
+  })
+}
+
+export const createPostgateQueryKey = (postUri: string) => [
+  'postgate-record',
+  postUri,
+]
+export function usePostgateQuery({postUri}: {postUri: string}) {
+  const agent = useAgent()
+  return useQuery({
+    staleTime: STALE.SECONDS.THIRTY,
+    queryKey: createPostgateQueryKey(postUri),
+    async queryFn() {
+      return (await getPostgateRecord({agent, postUri})) ?? null
+    },
+  })
+}
+
+export function useWritePostgateMutation() {
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+  return useMutation({
+    mutationFn: async ({
+      postUri,
+      postgate,
+    }: {
+      postUri: string
+      postgate: AppBskyFeedPostgate.Record
+    }) => {
+      return writePostgateRecord({
+        agent,
+        postUri,
+        postgate,
+      })
+    },
+    onSuccess(_, {postUri}) {
+      queryClient.invalidateQueries({
+        queryKey: createPostgateQueryKey(postUri),
+      })
+    },
+  })
+}
+
+export function useToggleQuoteDetachmentMutation() {
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+  const getPosts = useGetPosts()
+  const prevEmbed = React.useRef<AppBskyFeedDefs.PostView['embed']>()
+
+  return useMutation({
+    mutationFn: async ({
+      post,
+      quoteUri,
+      action,
+    }: {
+      post: AppBskyFeedDefs.PostView
+      quoteUri: string
+      action: 'detach' | 'reattach'
+    }) => {
+      // cache here since post shadow mutates original object
+      prevEmbed.current = post.embed
+
+      if (action === 'detach') {
+        updatePostShadow(queryClient, post.uri, {
+          embed: createMaybeDetachedQuoteEmbed({
+            post,
+            quote: undefined,
+            quoteUri,
+            detached: true,
+          }),
+        })
+      }
+
+      await upsertPostgate({agent, postUri: quoteUri}, async prev => {
+        if (prev) {
+          if (action === 'detach') {
+            return mergePostgateRecords(prev, {
+              detachedEmbeddingUris: [post.uri],
+            })
+          } else if (action === 'reattach') {
+            return {
+              ...prev,
+              detachedEmbeddingUris:
+                prev.detachedEmbeddingUris?.filter(uri => uri !== post.uri) ||
+                [],
+            }
+          }
+        } else {
+          if (action === 'detach') {
+            return createPostgateRecord({
+              post: quoteUri,
+              detachedEmbeddingUris: [post.uri],
+            })
+          }
+        }
+      })
+    },
+    async onSuccess(_data, {post, quoteUri, action}) {
+      if (action === 'reattach') {
+        try {
+          const [quote] = await getPosts({uris: [quoteUri]})
+          updatePostShadow(queryClient, post.uri, {
+            embed: createMaybeDetachedQuoteEmbed({
+              post,
+              quote,
+              quoteUri: undefined,
+              detached: false,
+            }),
+          })
+        } catch (e: any) {
+          // ok if this fails, it's just optimistic UI
+          logger.error(`Postgate: failed to get quote post for re-attachment`, {
+            safeMessage: e.message,
+          })
+        }
+      }
+    },
+    onError(_, {post, action}) {
+      if (action === 'detach' && prevEmbed.current) {
+        // detach failed, add the embed back
+        if (
+          AppBskyEmbedRecord.isView(prevEmbed.current) ||
+          AppBskyEmbedRecordWithMedia.isView(prevEmbed.current)
+        ) {
+          updatePostShadow(queryClient, post.uri, {
+            embed: prevEmbed.current,
+          })
+        }
+      }
+    },
+    onSettled() {
+      prevEmbed.current = undefined
+    },
+  })
+}
+
+export function useToggleQuotepostEnabledMutation() {
+  const agent = useAgent()
+
+  return useMutation({
+    mutationFn: async ({
+      postUri,
+      action,
+    }: {
+      postUri: string
+      action: 'enable' | 'disable'
+    }) => {
+      await upsertPostgate({agent, postUri: postUri}, async prev => {
+        if (prev) {
+          if (action === 'disable') {
+            return mergePostgateRecords(prev, {
+              embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}],
+            })
+          } else if (action === 'enable') {
+            return {
+              ...prev,
+              embeddingRules: [],
+            }
+          }
+        } else {
+          if (action === 'disable') {
+            return createPostgateRecord({
+              post: postUri,
+              embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}],
+            })
+          }
+        }
+      })
+    },
+  })
+}
diff --git a/src/state/queries/postgate/util.ts b/src/state/queries/postgate/util.ts
new file mode 100644
index 000000000..21509c3ac
--- /dev/null
+++ b/src/state/queries/postgate/util.ts
@@ -0,0 +1,196 @@
+import {
+  AppBskyEmbedRecord,
+  AppBskyEmbedRecordWithMedia,
+  AppBskyFeedDefs,
+  AppBskyFeedPostgate,
+  AtUri,
+} from '@atproto/api'
+
+export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate'
+
+export function createPostgateRecord(
+  postgate: Partial<AppBskyFeedPostgate.Record> & {
+    post: AppBskyFeedPostgate.Record['post']
+  },
+): AppBskyFeedPostgate.Record {
+  return {
+    $type: POSTGATE_COLLECTION,
+    createdAt: new Date().toISOString(),
+    post: postgate.post,
+    detachedEmbeddingUris: postgate.detachedEmbeddingUris || [],
+    embeddingRules: postgate.embeddingRules || [],
+  }
+}
+
+export function mergePostgateRecords(
+  prev: AppBskyFeedPostgate.Record,
+  next: Partial<AppBskyFeedPostgate.Record>,
+) {
+  const detachedEmbeddingUris = Array.from(
+    new Set([
+      ...(prev.detachedEmbeddingUris || []),
+      ...(next.detachedEmbeddingUris || []),
+    ]),
+  )
+  const embeddingRules = [
+    ...(prev.embeddingRules || []),
+    ...(next.embeddingRules || []),
+  ].filter(
+    (rule, i, all) => all.findIndex(_rule => _rule.$type === rule.$type) === i,
+  )
+  return createPostgateRecord({
+    post: prev.post,
+    detachedEmbeddingUris,
+    embeddingRules,
+  })
+}
+
+export function createEmbedViewDetachedRecord({uri}: {uri: string}) {
+  const record: AppBskyEmbedRecord.ViewDetached = {
+    $type: 'app.bsky.embed.record#viewDetached',
+    uri,
+    detached: true,
+  }
+  return {
+    $type: 'app.bsky.embed.record#view',
+    record,
+  }
+}
+
+export function createMaybeDetachedQuoteEmbed({
+  post,
+  quote,
+  quoteUri,
+  detached,
+}:
+  | {
+      post: AppBskyFeedDefs.PostView
+      quote: AppBskyFeedDefs.PostView
+      quoteUri: undefined
+      detached: false
+    }
+  | {
+      post: AppBskyFeedDefs.PostView
+      quote: undefined
+      quoteUri: string
+      detached: true
+    }): AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined {
+  if (AppBskyEmbedRecord.isView(post.embed)) {
+    if (detached) {
+      return createEmbedViewDetachedRecord({uri: quoteUri})
+    } else {
+      return createEmbedRecordView({post: quote})
+    }
+  } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
+    if (detached) {
+      return {
+        ...post.embed,
+        record: createEmbedViewDetachedRecord({uri: quoteUri}),
+      }
+    } else {
+      return createEmbedRecordWithMediaView({post, quote})
+    }
+  }
+}
+
+export function createEmbedViewRecordFromPost(
+  post: AppBskyFeedDefs.PostView,
+): AppBskyEmbedRecord.ViewRecord {
+  return {
+    $type: 'app.bsky.embed.record#viewRecord',
+    uri: post.uri,
+    cid: post.cid,
+    author: post.author,
+    value: post.record,
+    labels: post.labels,
+    replyCount: post.replyCount,
+    repostCount: post.repostCount,
+    likeCount: post.likeCount,
+    indexedAt: post.indexedAt,
+  }
+}
+
+export function createEmbedRecordView({
+  post,
+}: {
+  post: AppBskyFeedDefs.PostView
+}): AppBskyEmbedRecord.View {
+  return {
+    $type: 'app.bsky.embed.record#view',
+    record: createEmbedViewRecordFromPost(post),
+  }
+}
+
+export function createEmbedRecordWithMediaView({
+  post,
+  quote,
+}: {
+  post: AppBskyFeedDefs.PostView
+  quote: AppBskyFeedDefs.PostView
+}): AppBskyEmbedRecordWithMedia.View | undefined {
+  if (!AppBskyEmbedRecordWithMedia.isView(post.embed)) return
+  return {
+    ...(post.embed || {}),
+    record: {
+      record: createEmbedViewRecordFromPost(quote),
+    },
+  }
+}
+
+export function getMaybeDetachedQuoteEmbed({
+  viewerDid,
+  post,
+}: {
+  viewerDid: string
+  post: AppBskyFeedDefs.PostView
+}) {
+  if (AppBskyEmbedRecord.isView(post.embed)) {
+    // detached
+    if (AppBskyEmbedRecord.isViewDetached(post.embed.record)) {
+      const urip = new AtUri(post.embed.record.uri)
+      return {
+        embed: post.embed,
+        uri: urip.toString(),
+        isOwnedByViewer: urip.host === viewerDid,
+        isDetached: true,
+      }
+    }
+
+    // post
+    if (AppBskyEmbedRecord.isViewRecord(post.embed.record)) {
+      const urip = new AtUri(post.embed.record.uri)
+      return {
+        embed: post.embed,
+        uri: urip.toString(),
+        isOwnedByViewer: urip.host === viewerDid,
+        isDetached: false,
+      }
+    }
+  } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
+    // detached
+    if (AppBskyEmbedRecord.isViewDetached(post.embed.record.record)) {
+      const urip = new AtUri(post.embed.record.record.uri)
+      return {
+        embed: post.embed,
+        uri: urip.toString(),
+        isOwnedByViewer: urip.host === viewerDid,
+        isDetached: true,
+      }
+    }
+
+    // post
+    if (AppBskyEmbedRecord.isViewRecord(post.embed.record.record)) {
+      const urip = new AtUri(post.embed.record.record.uri)
+      return {
+        embed: post.embed,
+        uri: urip.toString(),
+        isOwnedByViewer: urip.host === viewerDid,
+        isDetached: false,
+      }
+    }
+  }
+}
+
+export const embeddingRules = {
+  disableRule: {$type: 'app.bsky.feed.postgate#disableRule'},
+}
diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts
deleted file mode 100644
index 8b6aeba6c..000000000
--- a/src/state/queries/threadgate.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
-
-export type ThreadgateSetting =
-  | {type: 'nobody'}
-  | {type: 'mention'}
-  | {type: 'following'}
-  | {type: 'list'; list: unknown}
-
-export function threadgateViewToSettings(
-  threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
-): ThreadgateSetting[] {
-  const record =
-    threadgate &&
-    AppBskyFeedThreadgate.isRecord(threadgate.record) &&
-    AppBskyFeedThreadgate.validateRecord(threadgate.record).success
-      ? threadgate.record
-      : null
-  if (!record) {
-    return []
-  }
-  if (!record.allow?.length) {
-    return [{type: 'nobody'}]
-  }
-  const settings: ThreadgateSetting[] = record.allow
-    .map(allow => {
-      let setting: ThreadgateSetting | undefined
-      if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
-        setting = {type: 'mention'}
-      } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
-        setting = {type: 'following'}
-      } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
-        setting = {type: 'list', list: allow.list}
-      }
-      return setting
-    })
-    .filter(n => !!n)
-  return settings
-}
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)
+      }
+    },
+  })
+}
diff --git a/src/state/queries/threadgate/types.ts b/src/state/queries/threadgate/types.ts
new file mode 100644
index 000000000..0cbea311c
--- /dev/null
+++ b/src/state/queries/threadgate/types.ts
@@ -0,0 +1,6 @@
+export type ThreadgateAllowUISetting =
+  | {type: 'everybody'}
+  | {type: 'nobody'}
+  | {type: 'mention'}
+  | {type: 'following'}
+  | {type: 'list'; list: unknown}
diff --git a/src/state/queries/threadgate/util.ts b/src/state/queries/threadgate/util.ts
new file mode 100644
index 000000000..09ae0a0c1
--- /dev/null
+++ b/src/state/queries/threadgate/util.ts
@@ -0,0 +1,141 @@
+import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
+
+import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
+
+export function threadgateViewToAllowUISetting(
+  threadgateView: AppBskyFeedDefs.ThreadgateView | undefined,
+): ThreadgateAllowUISetting[] {
+  const threadgate =
+    threadgateView &&
+    AppBskyFeedThreadgate.isRecord(threadgateView.record) &&
+    AppBskyFeedThreadgate.validateRecord(threadgateView.record).success
+      ? threadgateView.record
+      : undefined
+  return threadgateRecordToAllowUISetting(threadgate)
+}
+
+/**
+ * Converts a full {@link AppBskyFeedThreadgate.Record} to a list of
+ * {@link ThreadgateAllowUISetting}, for use by app UI.
+ */
+export function threadgateRecordToAllowUISetting(
+  threadgate: AppBskyFeedThreadgate.Record | undefined,
+): ThreadgateAllowUISetting[] {
+  /*
+   * If `threadgate` doesn't exist (default), or if `threadgate.allow === undefined`, it means
+   * anyone can reply.
+   *
+   * If `threadgate.allow === []` it means no one can reply, and we translate to UI code
+   * here. This was a historical choice, and we have no lexicon representation
+   * for 'replies disabled' other than an empty array.
+   */
+  if (!threadgate || threadgate.allow === undefined) {
+    return [{type: 'everybody'}]
+  }
+  if (threadgate.allow.length === 0) {
+    return [{type: 'nobody'}]
+  }
+
+  const settings: ThreadgateAllowUISetting[] = threadgate.allow
+    .map(allow => {
+      let setting: ThreadgateAllowUISetting | undefined
+      if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
+        setting = {type: 'mention'}
+      } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
+        setting = {type: 'following'}
+      } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
+        setting = {type: 'list', list: allow.list}
+      }
+      return setting
+    })
+    .filter(n => !!n)
+  return settings
+}
+
+/**
+ * Converts an array of {@link ThreadgateAllowUISetting} to the `allow` prop on
+ * {@link AppBskyFeedThreadgate.Record}.
+ *
+ * If the `allow` property on the record is undefined, we infer that to mean
+ * that everyone can reply. If it's an empty array, we infer that to mean that
+ * no one can reply.
+ */
+export function threadgateAllowUISettingToAllowRecordValue(
+  threadgate: ThreadgateAllowUISetting[],
+): AppBskyFeedThreadgate.Record['allow'] {
+  if (threadgate.find(v => v.type === 'everybody')) {
+    return undefined
+  }
+
+  let allow: (
+    | AppBskyFeedThreadgate.MentionRule
+    | AppBskyFeedThreadgate.FollowingRule
+    | AppBskyFeedThreadgate.ListRule
+  )[] = []
+
+  if (!threadgate.find(v => v.type === 'nobody')) {
+    for (const rule of threadgate) {
+      if (rule.type === 'mention') {
+        allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'})
+      } else if (rule.type === 'following') {
+        allow.push({$type: 'app.bsky.feed.threadgate#followingRule'})
+      } else if (rule.type === 'list') {
+        allow.push({
+          $type: 'app.bsky.feed.threadgate#listRule',
+          list: rule.list,
+        })
+      }
+    }
+  }
+
+  return allow
+}
+
+/**
+ * Merges two {@link AppBskyFeedThreadgate.Record} objects, combining their
+ * `allow` and `hiddenReplies` arrays and de-deduplicating them.
+ *
+ * Note: `allow` can be undefined here, be sure you don't accidentally set it
+ * to an empty array. See other comments in this file.
+ */
+export function mergeThreadgateRecords(
+  prev: AppBskyFeedThreadgate.Record,
+  next: Partial<AppBskyFeedThreadgate.Record>,
+): AppBskyFeedThreadgate.Record {
+  // can be undefined if everyone can reply!
+  const allow: AppBskyFeedThreadgate.Record['allow'] | undefined =
+    prev.allow || next.allow
+      ? [...(prev.allow || []), ...(next.allow || [])].filter(
+          (v, i, a) => a.findIndex(t => t.$type === v.$type) === i,
+        )
+      : undefined
+  const hiddenReplies = Array.from(
+    new Set([...(prev.hiddenReplies || []), ...(next.hiddenReplies || [])]),
+  )
+
+  return createThreadgateRecord({
+    post: prev.post,
+    allow, // can be undefined!
+    hiddenReplies,
+  })
+}
+
+/**
+ * Create a new {@link AppBskyFeedThreadgate.Record} object with the given
+ * properties.
+ */
+export function createThreadgateRecord(
+  threadgate: Partial<AppBskyFeedThreadgate.Record>,
+): AppBskyFeedThreadgate.Record {
+  if (!threadgate.post) {
+    throw new Error('Cannot create a threadgate record without a post URI')
+  }
+
+  return {
+    $type: 'app.bsky.feed.threadgate',
+    post: threadgate.post,
+    createdAt: new Date().toISOString(),
+    allow: threadgate.allow, // can be undefined!
+    hiddenReplies: threadgate.hiddenReplies || [],
+  }
+}
diff --git a/src/state/threadgate-hidden-replies.tsx b/src/state/threadgate-hidden-replies.tsx
new file mode 100644
index 000000000..06fc22366
--- /dev/null
+++ b/src/state/threadgate-hidden-replies.tsx
@@ -0,0 +1,69 @@
+import React from 'react'
+
+type StateContext = {
+  uris: Set<string>
+  recentlyUnhiddenUris: Set<string>
+}
+type ApiContext = {
+  addHiddenReplyUri: (uri: string) => void
+  removeHiddenReplyUri: (uri: string) => void
+}
+
+const StateContext = React.createContext<StateContext>({
+  uris: new Set(),
+  recentlyUnhiddenUris: new Set(),
+})
+
+const ApiContext = React.createContext<ApiContext>({
+  addHiddenReplyUri: () => {},
+  removeHiddenReplyUri: () => {},
+})
+
+export function Provider({children}: {children: React.ReactNode}) {
+  const [uris, setHiddenReplyUris] = React.useState<Set<string>>(new Set())
+  const [recentlyUnhiddenUris, setRecentlyUnhiddenUris] = React.useState<
+    Set<string>
+  >(new Set())
+
+  const stateCtx = React.useMemo(
+    () => ({
+      uris,
+      recentlyUnhiddenUris,
+    }),
+    [uris, recentlyUnhiddenUris],
+  )
+
+  const apiCtx = React.useMemo(
+    () => ({
+      addHiddenReplyUri(uri: string) {
+        setHiddenReplyUris(prev => new Set(prev.add(uri)))
+        setRecentlyUnhiddenUris(prev => {
+          prev.delete(uri)
+          return new Set(prev)
+        })
+      },
+      removeHiddenReplyUri(uri: string) {
+        setHiddenReplyUris(prev => {
+          prev.delete(uri)
+          return new Set(prev)
+        })
+        setRecentlyUnhiddenUris(prev => new Set(prev.add(uri)))
+      },
+    }),
+    [setHiddenReplyUris],
+  )
+
+  return (
+    <ApiContext.Provider value={apiCtx}>
+      <StateContext.Provider value={stateCtx}>{children}</StateContext.Provider>
+    </ApiContext.Provider>
+  )
+}
+
+export function useThreadgateHiddenReplyUris() {
+  return React.useContext(StateContext)
+}
+
+export function useThreadgateHiddenReplyUrisAPI() {
+  return React.useContext(ApiContext)
+}