about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-08-21 21:20:45 -0500
committerGitHub <noreply@github.com>2024-08-21 19:20:45 -0700
commit6616a6467ec53aa71e5f823c2d8c46dc01442703 (patch)
tree5e49d6916bc9b9fc71a475cf0d02f169c744bf59
parent56ab5e177fa2b24d0e5d9d969aa37532b96128da (diff)
downloadvoidsky-6616a6467ec53aa71e5f823c2d8c46dc01442703.tar.zst
Detached QPs and hidden replies (#4878)
Co-authored-by: Hailey <me@haileyok.com>
-rw-r--r--assets/icons/eye_stroke2_corner0_rounded.svg1
-rw-r--r--package.json2
-rw-r--r--src/App.native.tsx33
-rw-r--r--src/App.web.tsx29
-rw-r--r--src/components/Pills.tsx11
-rw-r--r--src/components/WhoCanReply.tsx293
-rw-r--r--src/components/dialogs/PostInteractionSettingsDialog.tsx538
-rw-r--r--src/components/dialogs/ThreadgateEditor.tsx217
-rw-r--r--src/components/icons/Eye.tsx5
-rw-r--r--src/components/moderation/ModerationDetailsDialog.tsx19
-rw-r--r--src/components/moderation/PostAlerts.tsx14
-rw-r--r--src/lib/api/index.ts105
-rw-r--r--src/lib/link-meta/bsky.ts8
-rw-r--r--src/lib/moderation.ts13
-rw-r--r--src/lib/moderation/useModerationCauseDescription.ts27
-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
-rw-r--r--src/view/com/composer/Composer.tsx69
-rw-r--r--src/view/com/composer/threadgate/ThreadgateBtn.tsx51
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts18
-rw-r--r--src/view/com/post-thread/PostQuotes.tsx5
-rw-r--r--src/view/com/post-thread/PostThread.tsx65
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx34
-rw-r--r--src/view/com/posts/FeedItem.tsx69
-rw-r--r--src/view/com/posts/FeedSlice.tsx4
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx262
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx5
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx29
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.web.tsx15
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx18
-rw-r--r--src/view/screens/DebugMod.tsx1
-rw-r--r--yarn.lock16
41 files changed, 2579 insertions, 617 deletions
diff --git a/assets/icons/eye_stroke2_corner0_rounded.svg b/assets/icons/eye_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..035daa6e1
--- /dev/null
+++ b/assets/icons/eye_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clip-rule="evenodd"/></svg>
diff --git a/package.json b/package.json
index 61064804b..8cf7e2bec 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,7 @@
     "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
   },
   "dependencies": {
-    "@atproto/api": "^0.13.2",
+    "@atproto/api": "0.13.2",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index bce439a71..69c7629bf 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -50,6 +50,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
+import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
 import {TestCtrls} from '#/view/com/testing/TestCtrls'
 import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
 import * as Toast from '#/view/com/util/Toast'
@@ -122,21 +123,23 @@ function InnerApp() {
                           <ModerationOptsProvider>
                             <LoggedOutViewProvider>
                               <SelectedFeedProvider>
-                                <UnreadNotifsProvider>
-                                  <BackgroundNotificationPreferencesProvider>
-                                    <MutedThreadsProvider>
-                                      <TourProvider>
-                                        <ProgressGuideProvider>
-                                          <GestureHandlerRootView
-                                            style={s.h100pct}>
-                                            <TestCtrls />
-                                            <Shell />
-                                          </GestureHandlerRootView>
-                                        </ProgressGuideProvider>
-                                      </TourProvider>
-                                    </MutedThreadsProvider>
-                                  </BackgroundNotificationPreferencesProvider>
-                                </UnreadNotifsProvider>
+                                <HiddenRepliesProvider>
+                                  <UnreadNotifsProvider>
+                                    <BackgroundNotificationPreferencesProvider>
+                                      <MutedThreadsProvider>
+                                        <TourProvider>
+                                          <ProgressGuideProvider>
+                                            <GestureHandlerRootView
+                                              style={s.h100pct}>
+                                              <TestCtrls />
+                                              <Shell />
+                                            </GestureHandlerRootView>
+                                          </ProgressGuideProvider>
+                                        </TourProvider>
+                                      </MutedThreadsProvider>
+                                    </BackgroundNotificationPreferencesProvider>
+                                  </UnreadNotifsProvider>
+                                </HiddenRepliesProvider>
                               </SelectedFeedProvider>
                             </LoggedOutViewProvider>
                           </ModerationOptsProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index df6fbf244..9ec792530 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -39,6 +39,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
+import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
 import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
 import * as Toast from '#/view/com/util/Toast'
 import {ToastContainer} from '#/view/com/util/Toast.web'
@@ -105,19 +106,21 @@ function InnerApp() {
                         <ModerationOptsProvider>
                           <LoggedOutViewProvider>
                             <SelectedFeedProvider>
-                              <UnreadNotifsProvider>
-                                <BackgroundNotificationPreferencesProvider>
-                                  <MutedThreadsProvider>
-                                    <SafeAreaProvider>
-                                      <TourProvider>
-                                        <ProgressGuideProvider>
-                                          <Shell />
-                                        </ProgressGuideProvider>
-                                      </TourProvider>
-                                    </SafeAreaProvider>
-                                  </MutedThreadsProvider>
-                                </BackgroundNotificationPreferencesProvider>
-                              </UnreadNotifsProvider>
+                              <HiddenRepliesProvider>
+                                <UnreadNotifsProvider>
+                                  <BackgroundNotificationPreferencesProvider>
+                                    <MutedThreadsProvider>
+                                      <SafeAreaProvider>
+                                        <TourProvider>
+                                          <ProgressGuideProvider>
+                                            <Shell />
+                                          </ProgressGuideProvider>
+                                        </TourProvider>
+                                      </SafeAreaProvider>
+                                    </MutedThreadsProvider>
+                                  </BackgroundNotificationPreferencesProvider>
+                                </UnreadNotifsProvider>
+                              </HiddenRepliesProvider>
                             </SelectedFeedProvider>
                           </LoggedOutViewProvider>
                         </ModerationOptsProvider>
diff --git a/src/components/Pills.tsx b/src/components/Pills.tsx
index 2fff99937..742a11667 100644
--- a/src/components/Pills.tsx
+++ b/src/components/Pills.tsx
@@ -13,6 +13,15 @@ import {
 } from '#/components/moderation/ModerationDetailsDialog'
 import {Text} from '#/components/Typography'
 
+export type AppModerationCause =
+  | ModerationCause
+  | {
+      type: 'reply-hidden'
+      source: {type: 'user'; did: string}
+      priority: 6
+      downgraded?: boolean
+    }
+
 export type CommonProps = {
   size?: 'sm' | 'lg'
 }
@@ -40,7 +49,7 @@ export function Row({
 }
 
 export type LabelProps = {
-  cause: ModerationCause
+  cause: AppModerationCause
   disableDetailsDialog?: boolean
   noBg?: boolean
 } & CommonProps
diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx
index 1ffb4da39..ab6ef8293 100644
--- a/src/components/WhoCanReply.tsx
+++ b/src/components/WhoCanReply.tsx
@@ -1,39 +1,34 @@
 import React from 'react'
-import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
+import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
 import {
   AppBskyFeedDefs,
-  AppBskyFeedGetPostThread,
+  AppBskyFeedPost,
   AppBskyGraphDefs,
   AtUri,
-  BskyAgent,
 } from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useQueryClient} from '@tanstack/react-query'
 
-import {createThreadgate} from '#/lib/api'
-import {until} from '#/lib/async/until'
 import {HITSLOP_10} from '#/lib/constants'
 import {makeListLink, makeProfileLink} from '#/lib/routes/links'
-import {logger} from '#/logger'
 import {isNative} from '#/platform/detection'
-import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
 import {
-  ThreadgateSetting,
-  threadgateViewToSettings,
+  ThreadgateAllowUISetting,
+  threadgateViewToAllowUISetting,
 } from '#/state/queries/threadgate'
-import {useAgent} from '#/state/session'
-import * as Toast from 'view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import {useDialogControl} from '#/components/Dialog'
+import {
+  PostInteractionSettingsDialog,
+  usePrefetchPostInteractionSettings,
+} from '#/components/dialogs/PostInteractionSettingsDialog'
 import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
 import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
 import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
+import {InlineLinkText} from '#/components/Link'
 import {Text} from '#/components/Typography'
-import {TextLink} from '../view/com/util/Link'
-import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
 import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
 
 interface WhoCanReplyProps {
@@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
   const t = useTheme()
   const infoDialogControl = useDialogControl()
   const editDialogControl = useDialogControl()
-  const agent = useAgent()
-  const queryClient = useQueryClient()
 
-  const settings = React.useMemo(
-    () => threadgateViewToSettings(post.threadgate),
-    [post],
-  )
-  const isRootPost = !('reply' in post.record)
+  /*
+   * `WhoCanReply` is only used for root posts atm, in case this changes
+   * unexpectedly, we should check to make sure it's for sure the root URI.
+   */
+  const rootUri =
+    AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
+      ? post.record.reply.root.uri
+      : post.uri
+  const settings = React.useMemo(() => {
+    return threadgateViewToAllowUISetting(post.threadgate)
+  }, [post.threadgate])
 
-  if (!isRootPost) {
-    return null
-  }
-  if (!settings.length && !isThreadAuthor) {
-    return null
-  }
+  const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
+    postUri: post.uri,
+    rootPostUri: rootUri,
+  })
 
-  const isEverybody = settings.length === 0
-  const isNobody = !!settings.find(gate => gate.type === 'nobody')
-  const description = isEverybody
+  const anyoneCanReply =
+    settings.length === 1 && settings[0].type === 'everybody'
+  const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
+  const description = anyoneCanReply
     ? _(msg`Everybody can reply`)
-    : isNobody
+    : noOneCanReply
     ? _(msg`Replies disabled`)
     : _(msg`Some people can reply`)
 
-  const onPressEdit = () => {
+  const onPressOpen = () => {
     if (isNative && Keyboard.isVisible()) {
       Keyboard.dismiss()
     }
@@ -82,52 +80,23 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
     }
   }
 
-  const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
-    if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
-      return
-    }
-    try {
-      if (newSettings.length) {
-        await createThreadgate(agent, post.uri, newSettings)
-      } else {
-        await agent.api.com.atproto.repo.deleteRecord({
-          repo: agent.session!.did,
-          collection: 'app.bsky.feed.threadgate',
-          rkey: new AtUri(post.uri).rkey,
-        })
-      }
-      await whenAppViewReady(agent, post.uri, res => {
-        const thread = res.data.thread
-        if (AppBskyFeedDefs.isThreadViewPost(thread)) {
-          const fetchedSettings = threadgateViewToSettings(
-            thread.post.threadgate,
-          )
-          return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
-        }
-        return false
-      })
-      Toast.show(_(msg`Thread settings updated`))
-      queryClient.invalidateQueries({
-        queryKey: [POST_THREAD_RQKEY_ROOT],
-      })
-    } catch (err) {
-      Toast.show(
-        _(
-          msg`There was an issue. Please check your internet connection and try again.`,
-        ),
-        'xmark',
-      )
-      logger.error('Failed to edit threadgate', {message: err})
-    }
-  }
-
   return (
     <>
       <Button
         label={
           isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
         }
-        onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
+        onPress={onPressOpen}
+        {...(isThreadAuthor
+          ? Platform.select({
+              web: {
+                onHoverIn: prefetchPostInteractionSettings,
+              },
+              native: {
+                onPressIn: prefetchPostInteractionSettings,
+              },
+            })
+          : {})}
         hitSlop={HITSLOP_10}>
         {({hovered}) => (
           <View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
@@ -145,22 +114,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
               ]}>
               {description}
             </Text>
+
             {isThreadAuthor && (
               <PencilLine width={12} fill={t.palette.primary_500} />
             )}
           </View>
         )}
       </Button>
-      <WhoCanReplyDialog
-        control={infoDialogControl}
-        post={post}
-        settings={settings}
-      />
-      {isThreadAuthor && (
-        <ThreadgateEditorDialog
+
+      {isThreadAuthor ? (
+        <PostInteractionSettingsDialog
+          postUri={post.uri}
+          rootPostUri={rootUri}
           control={editDialogControl}
-          threadgate={settings}
-          onConfirm={onEditConfirm}
+          initialThreadgateView={post.threadgate}
+        />
+      ) : (
+        <WhoCanReplyDialog
+          control={infoDialogControl}
+          post={post}
+          settings={settings}
+          embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
         />
       )}
     </>
@@ -174,7 +148,7 @@ function Icon({
 }: {
   color: string
   width?: number
-  settings: ThreadgateSetting[]
+  settings: ThreadgateAllowUISetting[]
 }) {
   const isEverybody = settings.length === 0
   const isNobody = !!settings.find(gate => gate.type === 'nobody')
@@ -186,79 +160,84 @@ function WhoCanReplyDialog({
   control,
   post,
   settings,
+  embeddingDisabled,
 }: {
   control: Dialog.DialogControlProps
   post: AppBskyFeedDefs.PostView
-  settings: ThreadgateSetting[]
+  settings: ThreadgateAllowUISetting[]
+  embeddingDisabled: boolean
 }) {
+  const {_} = useLingui()
   return (
     <Dialog.Outer control={control}>
       <Dialog.Handle />
-      <WhoCanReplyDialogInner post={post} settings={settings} />
+      <Dialog.ScrollableInner
+        label={_(msg`Dialog: adjust who can interact with this post`)}
+        style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
+        <View style={[a.gap_sm]}>
+          <Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
+            <Trans>Who can interact with this post?</Trans>
+          </Text>
+          <Rules
+            post={post}
+            settings={settings}
+            embeddingDisabled={embeddingDisabled}
+          />
+        </View>
+      </Dialog.ScrollableInner>
     </Dialog.Outer>
   )
 }
 
-function WhoCanReplyDialogInner({
-  post,
-  settings,
-}: {
-  post: AppBskyFeedDefs.PostView
-  settings: ThreadgateSetting[]
-}) {
-  const {_} = useLingui()
-  return (
-    <Dialog.ScrollableInner
-      label={_(msg`Who can reply dialog`)}
-      style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
-      <View style={[a.gap_sm]}>
-        <Text style={[a.font_bold, a.text_xl]}>
-          <Trans>Who can reply?</Trans>
-        </Text>
-        <Rules post={post} settings={settings} />
-      </View>
-    </Dialog.ScrollableInner>
-  )
-}
-
 function Rules({
   post,
   settings,
+  embeddingDisabled,
 }: {
   post: AppBskyFeedDefs.PostView
-  settings: ThreadgateSetting[]
+  settings: ThreadgateAllowUISetting[]
+  embeddingDisabled: boolean
 }) {
   const t = useTheme()
+
   return (
-    <Text
-      style={[
-        a.text_md,
-        a.leading_tight,
-        a.flex_wrap,
-        t.atoms.text_contrast_medium,
-      ]}>
-      {!settings.length ? (
-        <Trans>Everybody can reply</Trans>
-      ) : settings[0].type === 'nobody' ? (
-        <Trans>Replies to this thread are disabled</Trans>
-      ) : (
-        <Trans>
-          Only{' '}
-          {settings.map((rule, i) => (
-            <>
-              <Rule
-                key={`rule-${i}`}
-                rule={rule}
-                post={post}
-                lists={post.threadgate!.lists}
-              />
-              <Separator key={`sep-${i}`} i={i} length={settings.length} />
-            </>
-          ))}{' '}
-          can reply
-        </Trans>
+    <>
+      <Text
+        style={[
+          a.text_sm,
+          a.leading_snug,
+          a.flex_wrap,
+          t.atoms.text_contrast_medium,
+        ]}>
+        {settings[0].type === 'everybody' ? (
+          <Trans>Everybody can reply to this post.</Trans>
+        ) : settings[0].type === 'nobody' ? (
+          <Trans>Replies to this post are disabled.</Trans>
+        ) : (
+          <Trans>
+            Only{' '}
+            {settings.map((rule, i) => (
+              <React.Fragment key={`rule-${i}`}>
+                <Rule rule={rule} post={post} lists={post.threadgate!.lists} />
+                <Separator i={i} length={settings.length} />
+              </React.Fragment>
+            ))}{' '}
+            can reply.
+          </Trans>
+        )}{' '}
+      </Text>
+      {embeddingDisabled && (
+        <Text
+          style={[
+            a.text_sm,
+            a.leading_snug,
+            a.flex_wrap,
+            t.atoms.text_contrast_medium,
+          ]}>
+          <Trans>No one but the author can quote this post.</Trans>
+        </Text>
       )}
-    </Text>
+    </>
   )
 }
 
@@ -267,11 +246,10 @@ function Rule({
   post,
   lists,
 }: {
-  rule: ThreadgateSetting
+  rule: ThreadgateAllowUISetting
   post: AppBskyFeedDefs.PostView
   lists: AppBskyGraphDefs.ListViewBasic[] | undefined
 }) {
-  const t = useTheme()
   if (rule.type === 'mention') {
     return <Trans>mentioned users</Trans>
   }
@@ -279,12 +257,12 @@ function Rule({
     return (
       <Trans>
         users followed by{' '}
-        <TextLink
-          type="sm"
-          href={makeProfileLink(post.author)}
-          text={`@${post.author.handle}`}
-          style={{color: t.palette.primary_500}}
-        />
+        <InlineLinkText
+          label={`@${post.author.handle}`}
+          to={makeProfileLink(post.author)}
+          style={[a.text_sm, a.leading_snug]}>
+          @{post.author.handle}
+        </InlineLinkText>
       </Trans>
     )
   }
@@ -294,12 +272,12 @@ function Rule({
       const listUrip = new AtUri(list.uri)
       return (
         <Trans>
-          <TextLink
-            type="sm"
-            href={makeListLink(listUrip.hostname, listUrip.rkey)}
-            text={list.name}
-            style={{color: t.palette.primary_500}}
-          />{' '}
+          <InlineLinkText
+            label={list.name}
+            to={makeListLink(listUrip.hostname, listUrip.rkey)}
+            style={[a.text_sm, a.leading_snug]}>
+            {list.name}
+          </InlineLinkText>{' '}
           members
         </Trans>
       )
@@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) {
   }
   return <>, </>
 }
-
-async function whenAppViewReady(
-  agent: BskyAgent,
-  uri: string,
-  fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
-) {
-  await until(
-    5, // 5 tries
-    1e3, // 1s delay between tries
-    fn,
-    () =>
-      agent.app.bsky.feed.getPostThread({
-        uri,
-        depth: 0,
-      }),
-  )
-}
diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx
new file mode 100644
index 000000000..a326602b7
--- /dev/null
+++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx
@@ -0,0 +1,538 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
+import isEqual from 'lodash.isequal'
+
+import {logger} from '#/logger'
+import {STALE} from '#/state/queries'
+import {useMyListsQuery} from '#/state/queries/my-lists'
+import {
+  createPostgateQueryKey,
+  getPostgateRecord,
+  usePostgateQuery,
+  useWritePostgateMutation,
+} from '#/state/queries/postgate'
+import {
+  createPostgateRecord,
+  embeddingRules,
+} from '#/state/queries/postgate/util'
+import {
+  createThreadgateViewQueryKey,
+  getThreadgateView,
+  ThreadgateAllowUISetting,
+  threadgateViewToAllowUISetting,
+  useSetThreadgateAllowMutation,
+  useThreadgateViewQuery,
+} from '#/state/queries/threadgate'
+import {useAgent, useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Divider} from '#/components/Divider'
+import * as Toggle from '#/components/forms/Toggle'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export type PostInteractionSettingsFormProps = {
+  onSave: () => void
+  isSaving?: boolean
+
+  postgate: AppBskyFeedPostgate.Record
+  onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
+
+  threadgateAllowUISettings: ThreadgateAllowUISetting[]
+  onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
+
+  replySettingsDisabled?: boolean
+}
+
+export function PostInteractionSettingsControlledDialog({
+  control,
+  ...rest
+}: PostInteractionSettingsFormProps & {
+  control: Dialog.DialogControlProps
+}) {
+  const {_} = useLingui()
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+      <Dialog.ScrollableInner
+        label={_(msg`Edit post interaction settings`)}
+        style={[{maxWidth: 500}, a.w_full]}>
+        <PostInteractionSettingsForm {...rest} />
+        <Dialog.Close />
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
+
+export type PostInteractionSettingsDialogProps = {
+  control: Dialog.DialogControlProps
+  /**
+   * URI of the post to edit the interaction settings for. Could be a root post
+   * or could be a reply.
+   */
+  postUri: string
+  /**
+   * The URI of the root post in the thread. Used to determine if the viewer
+   * owns the threadgate record and can therefore edit it.
+   */
+  rootPostUri: string
+  /**
+   * Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we
+   * happen to have one before opening the settings dialog.
+   */
+  initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
+}
+
+export function PostInteractionSettingsDialog(
+  props: PostInteractionSettingsDialogProps,
+) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
+      <PostInteractionSettingsDialogControlledInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+export function PostInteractionSettingsDialogControlledInner(
+  props: PostInteractionSettingsDialogProps,
+) {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const [isSaving, setIsSaving] = React.useState(false)
+
+  const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
+    useThreadgateViewQuery({postUri: props.rootPostUri})
+  const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({
+    postUri: props.postUri,
+  })
+
+  const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
+  const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
+
+  const [editedPostgate, setEditedPostgate] =
+    React.useState<AppBskyFeedPostgate.Record>()
+  const [editedAllowUISettings, setEditedAllowUISettings] =
+    React.useState<ThreadgateAllowUISetting[]>()
+
+  const isLoading = isLoadingThreadgate || isLoadingPostgate
+  const threadgateView = threadgateViewLoaded || props.initialThreadgateView
+  const isThreadgateOwnedByViewer = React.useMemo(() => {
+    return currentAccount?.did === new AtUri(props.rootPostUri).host
+  }, [props.rootPostUri, currentAccount?.did])
+
+  const postgateValue = React.useMemo(() => {
+    return (
+      editedPostgate || postgate || createPostgateRecord({post: props.postUri})
+    )
+  }, [postgate, editedPostgate, props.postUri])
+  const allowUIValue = React.useMemo(() => {
+    return (
+      editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
+    )
+  }, [threadgateView, editedAllowUISettings])
+
+  const onSave = React.useCallback(async () => {
+    if (!editedPostgate && !editedAllowUISettings) {
+      props.control.close()
+      return
+    }
+
+    setIsSaving(true)
+
+    try {
+      const requests = []
+
+      if (editedPostgate) {
+        requests.push(
+          writePostgateRecord({
+            postUri: props.postUri,
+            postgate: editedPostgate,
+          }),
+        )
+      }
+
+      if (editedAllowUISettings && isThreadgateOwnedByViewer) {
+        requests.push(
+          setThreadgateAllow({
+            postUri: props.rootPostUri,
+            allow: editedAllowUISettings,
+          }),
+        )
+      }
+
+      await Promise.all(requests)
+
+      props.control.close()
+    } catch (e: any) {
+      logger.error(`Failed to save post interaction settings`, {
+        context: 'PostInteractionSettingsDialogControlledInner',
+        safeMessage: e.message,
+      })
+      Toast.show(
+        _(
+          msg`There was an issue. Please check your internet connection and try again.`,
+        ),
+        'xmark',
+      )
+    } finally {
+      setIsSaving(false)
+    }
+  }, [
+    _,
+    props.postUri,
+    props.rootPostUri,
+    props.control,
+    editedPostgate,
+    editedAllowUISettings,
+    setIsSaving,
+    writePostgateRecord,
+    setThreadgateAllow,
+    isThreadgateOwnedByViewer,
+  ])
+
+  return (
+    <Dialog.ScrollableInner
+      label={_(msg`Edit post interaction settings`)}
+      style={[{maxWidth: 500}, a.w_full]}>
+      {isLoading ? (
+        <Loader size="xl" />
+      ) : (
+        <PostInteractionSettingsForm
+          replySettingsDisabled={!isThreadgateOwnedByViewer}
+          isSaving={isSaving}
+          onSave={onSave}
+          postgate={postgateValue}
+          onChangePostgate={setEditedPostgate}
+          threadgateAllowUISettings={allowUIValue}
+          onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
+        />
+      )}
+    </Dialog.ScrollableInner>
+  )
+}
+
+export function PostInteractionSettingsForm({
+  onSave,
+  isSaving,
+  postgate,
+  onChangePostgate,
+  threadgateAllowUISettings,
+  onChangeThreadgateAllowUISettings,
+  replySettingsDisabled,
+}: PostInteractionSettingsFormProps) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const control = Dialog.useDialogContext()
+  const {data: lists} = useMyListsQuery('curate')
+  const [quotesEnabled, setQuotesEnabled] = React.useState(
+    !(
+      postgate.embeddingRules &&
+      postgate.embeddingRules.find(
+        v => v.$type === embeddingRules.disableRule.$type,
+      )
+    ),
+  )
+
+  const onPressAudience = (setting: ThreadgateAllowUISetting) => {
+    // remove boolean values
+    let newSelected: ThreadgateAllowUISetting[] =
+      threadgateAllowUISettings.filter(
+        v => v.type !== 'nobody' && v.type !== 'everybody',
+      )
+    // toggle
+    const i = newSelected.findIndex(v => isEqual(v, setting))
+    if (i === -1) {
+      newSelected.push(setting)
+    } else {
+      newSelected.splice(i, 1)
+    }
+
+    onChangeThreadgateAllowUISettings(newSelected)
+  }
+
+  const onChangeQuotesEnabled = React.useCallback(
+    (enabled: boolean) => {
+      setQuotesEnabled(enabled)
+      onChangePostgate(
+        createPostgateRecord({
+          ...postgate,
+          embeddingRules: enabled ? [] : [embeddingRules.disableRule],
+        }),
+      )
+    },
+    [setQuotesEnabled, postgate, onChangePostgate],
+  )
+
+  const noOneCanReply = !!threadgateAllowUISettings.find(
+    v => v.type === 'nobody',
+  )
+
+  return (
+    <View>
+      <View style={[a.flex_1, a.gap_md]}>
+        <Text style={[a.text_2xl, a.font_bold]}>
+          <Trans>Post interaction settings</Trans>
+        </Text>
+
+        <View style={[a.gap_lg]}>
+          <Text style={[a.text_md]}>
+            <Trans>Customize who can interact with this post.</Trans>
+          </Text>
+
+          <Divider />
+
+          <View style={[a.gap_sm]}>
+            <Text style={[a.font_bold, a.text_lg]}>
+              <Trans>Quote settings</Trans>
+            </Text>
+
+            <Toggle.Item
+              name="quoteposts"
+              type="checkbox"
+              label={
+                quotesEnabled
+                  ? _(msg`Click to disable quote posts of this post.`)
+                  : _(msg`Click to enable quote posts of this post.`)
+              }
+              value={quotesEnabled}
+              onChange={onChangeQuotesEnabled}
+              style={[, a.justify_between, a.pt_xs]}>
+              <Text style={[t.atoms.text_contrast_medium]}>
+                {quotesEnabled ? (
+                  <Trans>Quote posts enabled</Trans>
+                ) : (
+                  <Trans>Quote posts disabled</Trans>
+                )}
+              </Text>
+              <Toggle.Switch />
+            </Toggle.Item>
+          </View>
+
+          <Divider />
+
+          {replySettingsDisabled && (
+            <View
+              style={[
+                a.px_md,
+                a.py_sm,
+                a.rounded_sm,
+                a.flex_row,
+                a.align_center,
+                a.gap_sm,
+                t.atoms.bg_contrast_25,
+              ]}>
+              <CircleInfo fill={t.atoms.text_contrast_low.color} />
+              <Text
+                style={[
+                  a.flex_1,
+                  a.leading_snug,
+                  t.atoms.text_contrast_medium,
+                ]}>
+                <Trans>
+                  Reply settings are chosen by the author of the thread
+                </Trans>
+              </Text>
+            </View>
+          )}
+
+          <View
+            style={[
+              a.gap_sm,
+              {
+                opacity: replySettingsDisabled ? 0.3 : 1,
+              },
+            ]}>
+            <Text style={[a.font_bold, a.text_lg]}>
+              <Trans>Reply settings</Trans>
+            </Text>
+
+            <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
+              <Trans>Allow replies from:</Trans>
+            </Text>
+
+            <View style={[a.flex_row, a.gap_sm]}>
+              <Selectable
+                label={_(msg`Everybody`)}
+                isSelected={
+                  !!threadgateAllowUISettings.find(v => v.type === 'everybody')
+                }
+                onPress={() =>
+                  onChangeThreadgateAllowUISettings([{type: 'everybody'}])
+                }
+                style={{flex: 1}}
+                disabled={replySettingsDisabled}
+              />
+              <Selectable
+                label={_(msg`Nobody`)}
+                isSelected={noOneCanReply}
+                onPress={() =>
+                  onChangeThreadgateAllowUISettings([{type: 'nobody'}])
+                }
+                style={{flex: 1}}
+                disabled={replySettingsDisabled}
+              />
+            </View>
+
+            {!noOneCanReply && (
+              <>
+                <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
+                  <Trans>Or combine these options:</Trans>
+                </Text>
+
+                <View style={[a.gap_sm]}>
+                  <Selectable
+                    label={_(msg`Mentioned users`)}
+                    isSelected={
+                      !!threadgateAllowUISettings.find(
+                        v => v.type === 'mention',
+                      )
+                    }
+                    onPress={() => onPressAudience({type: 'mention'})}
+                    disabled={replySettingsDisabled}
+                  />
+                  <Selectable
+                    label={_(msg`Followed users`)}
+                    isSelected={
+                      !!threadgateAllowUISettings.find(
+                        v => v.type === 'following',
+                      )
+                    }
+                    onPress={() => onPressAudience({type: 'following'})}
+                    disabled={replySettingsDisabled}
+                  />
+                  {lists && lists.length > 0
+                    ? lists.map(list => (
+                        <Selectable
+                          key={list.uri}
+                          label={_(msg`Users in "${list.name}"`)}
+                          isSelected={
+                            !!threadgateAllowUISettings.find(
+                              v => v.type === 'list' && v.list === list.uri,
+                            )
+                          }
+                          onPress={() =>
+                            onPressAudience({type: 'list', list: list.uri})
+                          }
+                          disabled={replySettingsDisabled}
+                        />
+                      ))
+                    : // No loading states to avoid jumps for the common case (no lists)
+                      null}
+                </View>
+              </>
+            )}
+          </View>
+        </View>
+      </View>
+
+      <Button
+        label={_(msg`Save`)}
+        onPress={onSave}
+        onAccessibilityEscape={control.close}
+        color="primary"
+        size="medium"
+        variant="solid"
+        style={a.mt_xl}>
+        <ButtonText>{_(msg`Save`)}</ButtonText>
+        {isSaving && <ButtonIcon icon={Loader} position="right" />}
+      </Button>
+    </View>
+  )
+}
+
+function Selectable({
+  label,
+  isSelected,
+  onPress,
+  style,
+  disabled,
+}: {
+  label: string
+  isSelected: boolean
+  onPress: () => void
+  style?: StyleProp<ViewStyle>
+  disabled?: boolean
+}) {
+  const t = useTheme()
+  return (
+    <Button
+      disabled={disabled}
+      onPress={onPress}
+      label={label}
+      accessibilityRole="checkbox"
+      aria-checked={isSelected}
+      accessibilityState={{
+        checked: isSelected,
+      }}
+      style={a.flex_1}>
+      {({hovered, focused}) => (
+        <View
+          style={[
+            a.flex_1,
+            a.flex_row,
+            a.align_center,
+            a.justify_between,
+            a.rounded_sm,
+            a.p_md,
+            {height: 40}, // for consistency with checkmark icon visible or not
+            t.atoms.bg_contrast_50,
+            (hovered || focused) && t.atoms.bg_contrast_100,
+            isSelected && {
+              backgroundColor: t.palette.primary_100,
+            },
+            style,
+          ]}>
+          <Text style={[a.text_sm, isSelected && a.font_semibold]}>
+            {label}
+          </Text>
+          {isSelected ? (
+            <Check size="sm" fill={t.palette.primary_500} />
+          ) : (
+            <View />
+          )}
+        </View>
+      )}
+    </Button>
+  )
+}
+
+export function usePrefetchPostInteractionSettings({
+  postUri,
+  rootPostUri,
+}: {
+  postUri: string
+  rootPostUri: string
+}) {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+
+  return React.useCallback(async () => {
+    try {
+      await Promise.all([
+        queryClient.prefetchQuery({
+          queryKey: createPostgateQueryKey(postUri),
+          queryFn: () => getPostgateRecord({agent, postUri}),
+          staleTime: STALE.SECONDS.THIRTY,
+        }),
+        queryClient.prefetchQuery({
+          queryKey: createThreadgateViewQueryKey(rootPostUri),
+          queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
+          staleTime: STALE.SECONDS.THIRTY,
+        }),
+      ])
+    } catch (e: any) {
+      logger.error(`Failed to prefetch post interaction settings`, {
+        safeMessage: e.message,
+      })
+    }
+  }, [queryClient, agent, postUri, rootPostUri])
+}
diff --git a/src/components/dialogs/ThreadgateEditor.tsx b/src/components/dialogs/ThreadgateEditor.tsx
deleted file mode 100644
index 90483b3ad..000000000
--- a/src/components/dialogs/ThreadgateEditor.tsx
+++ /dev/null
@@ -1,217 +0,0 @@
-import React from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import isEqual from 'lodash.isequal'
-
-import {useMyListsQuery} from '#/state/queries/my-lists'
-import {ThreadgateSetting} from '#/state/queries/threadgate'
-import {atoms as a, useTheme} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
-import * as Dialog from '#/components/Dialog'
-import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
-import {Text} from '#/components/Typography'
-
-interface ThreadgateEditorDialogProps {
-  control: Dialog.DialogControlProps
-  threadgate: ThreadgateSetting[]
-  onChange?: (v: ThreadgateSetting[]) => void
-  onConfirm?: (v: ThreadgateSetting[]) => void
-}
-
-export function ThreadgateEditorDialog({
-  control,
-  threadgate,
-  onChange,
-  onConfirm,
-}: ThreadgateEditorDialogProps) {
-  return (
-    <Dialog.Outer control={control}>
-      <Dialog.Handle />
-      <DialogContent
-        seedThreadgate={threadgate}
-        onChange={onChange}
-        onConfirm={onConfirm}
-      />
-    </Dialog.Outer>
-  )
-}
-
-function DialogContent({
-  seedThreadgate,
-  onChange,
-  onConfirm,
-}: {
-  seedThreadgate: ThreadgateSetting[]
-  onChange?: (v: ThreadgateSetting[]) => void
-  onConfirm?: (v: ThreadgateSetting[]) => void
-}) {
-  const {_} = useLingui()
-  const control = Dialog.useDialogContext()
-  const {data: lists} = useMyListsQuery('curate')
-  const [draft, setDraft] = React.useState(seedThreadgate)
-
-  const [prevSeedThreadgate, setPrevSeedThreadgate] =
-    React.useState(seedThreadgate)
-  if (seedThreadgate !== prevSeedThreadgate) {
-    // New data flowed from above (e.g. due to update coming through).
-    setPrevSeedThreadgate(seedThreadgate)
-    setDraft(seedThreadgate) // Reset draft.
-  }
-
-  function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
-    setDraft(nextThreadgate)
-    onChange?.(nextThreadgate)
-  }
-
-  const onPressEverybody = () => {
-    updateThreadgate([])
-  }
-
-  const onPressNobody = () => {
-    updateThreadgate([{type: 'nobody'}])
-  }
-
-  const onPressAudience = (setting: ThreadgateSetting) => {
-    // remove nobody
-    let newSelected: ThreadgateSetting[] = draft.filter(
-      v => v.type !== 'nobody',
-    )
-    // toggle
-    const i = newSelected.findIndex(v => isEqual(v, setting))
-    if (i === -1) {
-      newSelected.push(setting)
-    } else {
-      newSelected.splice(i, 1)
-    }
-    updateThreadgate(newSelected)
-  }
-
-  const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`)
-  return (
-    <Dialog.ScrollableInner
-      label={_(msg`Choose who can reply`)}
-      style={[{maxWidth: 500}, a.w_full]}>
-      <View style={[a.flex_1, a.gap_md]}>
-        <Text style={[a.text_2xl, a.font_bold]}>
-          <Trans>Choose who can reply</Trans>
-        </Text>
-        <Text style={a.mt_xs}>
-          <Trans>Either choose "Everybody" or "Nobody"</Trans>
-        </Text>
-        <View style={[a.flex_row, a.gap_sm]}>
-          <Selectable
-            label={_(msg`Everybody`)}
-            isSelected={draft.length === 0}
-            onPress={onPressEverybody}
-            style={{flex: 1}}
-          />
-          <Selectable
-            label={_(msg`Nobody`)}
-            isSelected={!!draft.find(v => v.type === 'nobody')}
-            onPress={onPressNobody}
-            style={{flex: 1}}
-          />
-        </View>
-        <Text style={a.mt_md}>
-          <Trans>Or combine these options:</Trans>
-        </Text>
-        <View style={[a.gap_sm]}>
-          <Selectable
-            label={_(msg`Mentioned users`)}
-            isSelected={!!draft.find(v => v.type === 'mention')}
-            onPress={() => onPressAudience({type: 'mention'})}
-          />
-          <Selectable
-            label={_(msg`Followed users`)}
-            isSelected={!!draft.find(v => v.type === 'following')}
-            onPress={() => onPressAudience({type: 'following'})}
-          />
-          {lists && lists.length > 0
-            ? lists.map(list => (
-                <Selectable
-                  key={list.uri}
-                  label={_(msg`Users in "${list.name}"`)}
-                  isSelected={
-                    !!draft.find(v => v.type === 'list' && v.list === list.uri)
-                  }
-                  onPress={() =>
-                    onPressAudience({type: 'list', list: list.uri})
-                  }
-                />
-              ))
-            : // No loading states to avoid jumps for the common case (no lists)
-              null}
-        </View>
-      </View>
-      <Button
-        label={doneLabel}
-        onPress={() => {
-          control.close()
-          onConfirm?.(draft)
-        }}
-        onAccessibilityEscape={control.close}
-        color="primary"
-        size="medium"
-        variant="solid"
-        style={a.mt_xl}>
-        <ButtonText>{doneLabel}</ButtonText>
-      </Button>
-      <Dialog.Close />
-    </Dialog.ScrollableInner>
-  )
-}
-
-function Selectable({
-  label,
-  isSelected,
-  onPress,
-  style,
-}: {
-  label: string
-  isSelected: boolean
-  onPress: () => void
-  style?: StyleProp<ViewStyle>
-}) {
-  const t = useTheme()
-  return (
-    <Button
-      onPress={onPress}
-      label={label}
-      accessibilityHint="Select this option"
-      accessibilityRole="checkbox"
-      aria-checked={isSelected}
-      accessibilityState={{
-        checked: isSelected,
-      }}
-      style={a.flex_1}>
-      {({hovered, focused}) => (
-        <View
-          style={[
-            a.flex_1,
-            a.flex_row,
-            a.align_center,
-            a.justify_between,
-            a.rounded_sm,
-            a.p_md,
-            {height: 40}, // for consistency with checkmark icon visible or not
-            t.atoms.bg_contrast_50,
-            (hovered || focused) && t.atoms.bg_contrast_100,
-            isSelected && {
-              backgroundColor: t.palette.primary_100,
-            },
-            style,
-          ]}>
-          <Text style={[a.text_sm, isSelected && a.font_semibold]}>
-            {label}
-          </Text>
-          {isSelected ? (
-            <Check size="sm" fill={t.palette.primary_500} />
-          ) : (
-            <View />
-          )}
-        </View>
-      )}
-    </Button>
-  )
-}
diff --git a/src/components/icons/Eye.tsx b/src/components/icons/Eye.tsx
new file mode 100644
index 000000000..afa772e1d
--- /dev/null
+++ b/src/components/icons/Eye.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Eye_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
+})
diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx
index b8f02582c..d95717cf4 100644
--- a/src/components/moderation/ModerationDetailsDialog.tsx
+++ b/src/components/moderation/ModerationDetailsDialog.tsx
@@ -8,17 +8,19 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
 import {makeProfileLink} from '#/lib/routes/links'
 import {listUriToHref} from '#/lib/strings/url-helpers'
 import {isNative} from '#/platform/detection'
+import {useSession} from '#/state/session'
 import {atoms as a, useTheme} from '#/alf'
 import * as Dialog from '#/components/Dialog'
 import {Divider} from '#/components/Divider'
 import {InlineLinkText} from '#/components/Link'
+import {AppModerationCause} from '#/components/Pills'
 import {Text} from '#/components/Typography'
 
 export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
 
 export interface ModerationDetailsDialogProps {
   control: Dialog.DialogOuterProps['control']
-  modcause?: ModerationCause
+  modcause?: ModerationCause | AppModerationCause
 }
 
 export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
@@ -39,6 +41,7 @@ function ModerationDetailsDialogInner({
   const t = useTheme()
   const {_} = useLingui()
   const desc = useModerationCauseDescription(modcause)
+  const {currentAccount} = useSession()
 
   let name
   let description
@@ -105,6 +108,14 @@ function ModerationDetailsDialogInner({
   } else if (modcause.type === 'hidden') {
     name = _(msg`Post Hidden by You`)
     description = _(msg`You have hidden this post.`)
+  } else if (modcause.type === 'reply-hidden') {
+    const isYou = currentAccount?.did === modcause.source.did
+    name = isYou
+      ? _(msg`Reply Hidden by You`)
+      : _(msg`Reply Hidden by Thread Author`)
+    description = isYou
+      ? _(msg`You hid this reply.`)
+      : _(msg`The author of this thread has hidden this reply.`)
   } else if (modcause.type === 'label') {
     name = desc.name
     description = desc.description
@@ -119,12 +130,12 @@ function ModerationDetailsDialogInner({
       <Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
         {name}
       </Text>
-      <Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}>
+      <Text style={[t.atoms.text, a.text_md, a.leading_snug]}>
         {description}
       </Text>
 
       {modcause?.type === 'label' && (
-        <>
+        <View style={[a.pt_lg]}>
           <Divider />
           <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
             {modcause.source.type === 'user' ? (
@@ -143,7 +154,7 @@ function ModerationDetailsDialogInner({
               </Trans>
             )}
           </Text>
-        </>
+        </View>
       )}
 
       {isNative && <View style={{height: 40}} />}
diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx
index efbf18219..6c4e5f8c8 100644
--- a/src/components/moderation/PostAlerts.tsx
+++ b/src/components/moderation/PostAlerts.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {StyleProp, ViewStyle} from 'react-native'
-import {ModerationUI} from '@atproto/api'
+import {ModerationCause, ModerationUI} from '@atproto/api'
 
 import {getModerationCauseKey} from '#/lib/moderation'
 import * as Pills from '#/components/Pills'
@@ -9,13 +9,15 @@ export function PostAlerts({
   modui,
   size = 'sm',
   style,
+  additionalCauses,
 }: {
   modui: ModerationUI
   size?: Pills.CommonProps['size']
   includeMute?: boolean
   style?: StyleProp<ViewStyle>
+  additionalCauses?: ModerationCause[] | Pills.AppModerationCause[]
 }) {
-  if (!modui.alert && !modui.inform) {
+  if (!modui.alert && !modui.inform && !additionalCauses?.length) {
     return null
   }
 
@@ -37,6 +39,14 @@ export function PostAlerts({
           noBg={size === 'sm'}
         />
       ))}
+      {additionalCauses?.map(cause => (
+        <Pills.Label
+          key={getModerationCauseKey(cause)}
+          cause={cause}
+          size={size}
+          noBg={size === 'sm'}
+        />
+      ))}
     </Pills.Row>
   )
 }
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 658ed78de..94c8869a1 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -3,7 +3,7 @@ import {
   AppBskyEmbedImages,
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
-  AppBskyFeedThreadgate,
+  AppBskyFeedPostgate,
   BskyAgent,
   ComAtprotoLabelDefs,
   RichText,
@@ -11,7 +11,13 @@ import {
 import {AtUri} from '@atproto/api'
 
 import {logger} from '#/logger'
-import {ThreadgateSetting} from '#/state/queries/threadgate'
+import {writePostgateRecord} from '#/state/queries/postgate'
+import {
+  createThreadgateRecord,
+  ThreadgateAllowUISetting,
+  threadgateAllowUISettingToAllowRecordValue,
+  writeThreadgateRecord,
+} from '#/state/queries/threadgate'
 import {isNetworkError} from 'lib/strings/errors'
 import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
 import {isNative} from 'platform/detection'
@@ -44,7 +50,8 @@ interface PostOpts {
   extLink?: ExternalEmbedDraft
   images?: ImageModel[]
   labels?: string[]
-  threadgate?: ThreadgateSetting[]
+  threadgate: ThreadgateAllowUISetting[]
+  postgate: AppBskyFeedPostgate.Record
   onStateChange?: (state: string) => void
   langs?: string[]
 }
@@ -232,7 +239,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
       labels,
     })
   } catch (e: any) {
-    console.error(`Failed to create post: ${e.toString()}`)
+    logger.error(`Failed to create post`, {
+      safeMessage: e.message,
+    })
     if (isNetworkError(e)) {
       throw new Error(
         'Post failed to upload. Please check your Internet connection and try again.',
@@ -242,56 +251,52 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
     }
   }
 
-  try {
-    // TODO: this needs to be batch-created with the post!
-    if (opts.threadgate?.length) {
-      await createThreadgate(agent, res.uri, opts.threadgate)
+  if (opts.threadgate.some(tg => tg.type !== 'everybody')) {
+    try {
+      // TODO: this needs to be batch-created with the post!
+      await writeThreadgateRecord({
+        agent,
+        postUri: res.uri,
+        threadgate: createThreadgateRecord({
+          post: res.uri,
+          allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate),
+        }),
+      })
+    } catch (e: any) {
+      logger.error(`Failed to create threadgate`, {
+        context: 'composer',
+        safeMessage: e.message,
+      })
+      throw new Error(
+        'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
+      )
     }
-  } catch (e: any) {
-    console.error(`Failed to create threadgate: ${e.toString()}`)
-    throw new Error(
-      'Post reply-controls failed to be set. Your post was created but anyone can reply to it.',
-    )
   }
 
-  return res
-}
-
-export async function createThreadgate(
-  agent: BskyAgent,
-  postUri: string,
-  threadgate: ThreadgateSetting[],
-) {
-  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,
-        })
-      }
+  if (
+    opts.postgate.embeddingRules?.length ||
+    opts.postgate.detachedEmbeddingUris?.length
+  ) {
+    try {
+      // TODO: this needs to be batch-created with the post!
+      await writePostgateRecord({
+        agent,
+        postUri: res.uri,
+        postgate: {
+          ...opts.postgate,
+          post: res.uri,
+        },
+      })
+    } catch (e: any) {
+      logger.error(`Failed to create postgate`, {
+        context: 'composer',
+        safeMessage: e.message,
+      })
+      throw new Error(
+        'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
+      )
     }
   }
 
-  const postUrip = new AtUri(postUri)
-  await agent.api.com.atproto.repo.putRecord({
-    repo: agent.accountDid,
-    collection: 'app.bsky.feed.threadgate',
-    rkey: postUrip.rkey,
-    record: {
-      $type: 'app.bsky.feed.threadgate',
-      post: postUri,
-      allow,
-      createdAt: new Date().toISOString(),
-    },
-  })
+  return res
 }
diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts
index e3b4ea0c9..3d49f5237 100644
--- a/src/lib/link-meta/bsky.ts
+++ b/src/lib/link-meta/bsky.ts
@@ -107,6 +107,11 @@ export async function extractBskyMeta(
   return meta
 }
 
+export class EmbeddingDisabledError extends Error {
+  constructor() {
+    super('Embedding is disabled for this record')
+  }
+}
 export async function getPostAsQuote(
   getPost: ReturnType<typeof useGetPost>,
   url: string,
@@ -115,6 +120,9 @@ export async function getPostAsQuote(
   const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
   const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
   const post = await getPost({uri: uri})
+  if (post.viewer?.embeddingDisabled) {
+    throw new EmbeddingDisabledError()
+  }
   return {
     uri: post.uri,
     cid: post.cid,
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index 4105c2c2d..3c96deecb 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -1,17 +1,20 @@
 import {
-  ModerationCause,
-  ModerationUI,
-  InterpretedLabelValueDefinition,
-  LABELS,
   AppBskyLabelerDefs,
   BskyAgent,
+  InterpretedLabelValueDefinition,
+  LABELS,
+  ModerationCause,
   ModerationOpts,
+  ModerationUI,
 } from '@atproto/api'
 
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
+import {AppModerationCause} from '#/components/Pills'
 
-export function getModerationCauseKey(cause: ModerationCause): string {
+export function getModerationCauseKey(
+  cause: ModerationCause | AppModerationCause,
+): string {
   const source =
     cause.source.type === 'labeler'
       ? cause.source.did
diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts
index 01ffbe5cf..9dce0b565 100644
--- a/src/lib/moderation/useModerationCauseDescription.ts
+++ b/src/lib/moderation/useModerationCauseDescription.ts
@@ -8,11 +8,13 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {useLabelDefinitions} from '#/state/preferences'
+import {useSession} from '#/state/session'
 import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {Props as SVGIconProps} from '#/components/icons/common'
 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {AppModerationCause} from '#/components/Pills'
 import {useGlobalLabelStrings} from './useGlobalLabelStrings'
 import {getDefinition, getLabelStrings} from './useLabelInfo'
 
@@ -27,8 +29,9 @@ export interface ModerationCauseDescription {
 }
 
 export function useModerationCauseDescription(
-  cause: ModerationCause | undefined,
+  cause: ModerationCause | AppModerationCause | undefined,
 ): ModerationCauseDescription {
+  const {currentAccount} = useSession()
   const {_, i18n} = useLingui()
   const {labelDefs, labelers} = useLabelDefinitions()
   const globalLabelStrings = useGlobalLabelStrings()
@@ -111,6 +114,18 @@ export function useModerationCauseDescription(
         description: _(msg`You have hidden this post`),
       }
     }
+    if (cause.type === 'reply-hidden') {
+      const isMe = currentAccount?.did === cause.source.did
+      return {
+        icon: EyeSlash,
+        name: isMe
+          ? _(msg`Reply Hidden by You`)
+          : _(msg`Reply Hidden by Thread Author`),
+        description: isMe
+          ? _(msg`You hid this reply.`)
+          : _(msg`The author of this thread has hidden this reply.`),
+      }
+    }
     if (cause.type === 'label') {
       const def = cause.labelDef || getDefinition(labelDefs, cause.label)
       const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
@@ -150,5 +165,13 @@ export function useModerationCauseDescription(
       name: '',
       description: ``,
     }
-  }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale])
+  }, [
+    labelDefs,
+    labelers,
+    globalLabelStrings,
+    cause,
+    _,
+    i18n.locale,
+    currentAccount?.did,
+  ])
 }
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)
+}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 0efbe70e6..eefd0affc 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -58,9 +58,11 @@ import {
   useLanguagePrefs,
   useLanguagePrefsApi,
 } from '#/state/preferences/languages'
+import {createPostgateRecord} from '#/state/queries/postgate/util'
 import {useProfileQuery} from '#/state/queries/profile'
 import {Gif} from '#/state/queries/tenor'
-import {ThreadgateSetting} from '#/state/queries/threadgate'
+import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
+import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
 import {useUploadVideo} from '#/state/queries/video/video'
 import {useAgent, useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
@@ -81,9 +83,12 @@ import {State as VideoUploadState} from 'state/queries/video/video'
 import {ComposerOpts} from 'state/shell/composer'
 import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
 import {atoms as a, useTheme} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import * as Prompt from '#/components/Prompt'
+import {Text as NewText} from '#/components/Typography'
 import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed'
 import {Text} from '../util/text/Text'
 import * as Toast from '../util/Toast'
@@ -182,10 +187,14 @@ export const ComposePost = observer(function ComposePost({
   })
   const [publishOnUpload, setPublishOnUpload] = useState(false)
 
-  const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
+  const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError})
   const [extGif, setExtGif] = useState<Gif>()
   const [labels, setLabels] = useState<string[]>([])
-  const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
+  const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] =
+    useState<ThreadgateAllowUISetting[]>(
+      threadgateViewToAllowUISetting(undefined),
+    )
+  const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
 
   const gallery = useMemo(
     () => new GalleryModel(initImageUris),
@@ -335,7 +344,8 @@ export const ComposePost = observer(function ComposePost({
           quote,
           extLink,
           labels,
-          threadgate,
+          threadgate: threadgateAllowUISettings,
+          postgate,
           onStateChange: setProcessingState,
           langs: toPostLanguages(langPrefs.postLanguage),
         })
@@ -581,15 +591,40 @@ export const ComposePost = observer(function ComposePost({
             </View>
           )}
           {error !== '' && (
-            <View style={styles.errorLine}>
-              <View style={styles.errorIcon}>
-                <FontAwesomeIcon
-                  icon="exclamation"
-                  style={{color: colors.red4}}
-                  size={10}
-                />
+            <View style={[a.px_lg, a.pb_sm]}>
+              <View
+                style={[
+                  a.px_md,
+                  a.py_sm,
+                  a.rounded_sm,
+                  a.flex_row,
+                  a.gap_sm,
+                  t.atoms.bg_contrast_25,
+                  {
+                    paddingRight: 48,
+                  },
+                ]}>
+                <CircleInfo fill={t.palette.negative_400} />
+                <NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
+                  {error}
+                </NewText>
+                <Button
+                  label={_(msg`Dismiss error`)}
+                  size="tiny"
+                  color="secondary"
+                  variant="ghost"
+                  shape="round"
+                  style={[
+                    a.absolute,
+                    {
+                      top: a.py_sm.paddingTop,
+                      right: a.px_md.paddingRight,
+                    },
+                  ]}
+                  onPress={() => setError('')}>
+                  <ButtonIcon icon={X} />
+                </Button>
               </View>
-              <Text style={[s.red4, a.flex_1]}>{error}</Text>
             </View>
           )}
         </Animated.View>
@@ -680,8 +715,12 @@ export const ComposePost = observer(function ComposePost({
 
         {replyTo ? null : (
           <ThreadgateBtn
-            threadgate={threadgate}
-            onChange={setThreadgate}
+            postgate={postgate}
+            onChangePostgate={setPostgate}
+            threadgateAllowUISettings={threadgateAllowUISettings}
+            onChangeThreadgateAllowUISettings={
+              onChangeThreadgateAllowUISettings
+            }
             style={bottomBarAnimatedStyle}
           />
         )}
diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
index 6cf2eea2c..666473afd 100644
--- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx
+++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
@@ -1,27 +1,33 @@
 import React from 'react'
 import {Keyboard, StyleProp, ViewStyle} from 'react-native'
 import Animated, {AnimatedStyle} from 'react-native-reanimated'
+import {AppBskyFeedPostgate} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {isNative} from '#/platform/detection'
-import {ThreadgateSetting} from '#/state/queries/threadgate'
+import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
-import {ThreadgateEditorDialog} from '#/components/dialogs/ThreadgateEditor'
-import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
+import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog'
 import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
 import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
 
 export function ThreadgateBtn({
-  threadgate,
-  onChange,
+  postgate,
+  onChangePostgate,
+  threadgateAllowUISettings,
+  onChangeThreadgateAllowUISettings,
   style,
 }: {
-  threadgate: ThreadgateSetting[]
-  onChange: (v: ThreadgateSetting[]) => void
+  postgate: AppBskyFeedPostgate.Record
+  onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
+
+  threadgateAllowUISettings: ThreadgateAllowUISetting[]
+  onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
+
   style?: StyleProp<AnimatedStyle<ViewStyle>>
 }) {
   const {track} = useAnalytics()
@@ -38,13 +44,15 @@ export function ThreadgateBtn({
     control.open()
   }
 
-  const isEverybody = threadgate.length === 0
-  const isNobody = !!threadgate.find(gate => gate.type === 'nobody')
-  const label = isEverybody
-    ? _(msg`Everybody can reply`)
-    : isNobody
-    ? _(msg`Nobody can reply`)
-    : _(msg`Some people can reply`)
+  const anyoneCanReply =
+    threadgateAllowUISettings.length === 1 &&
+    threadgateAllowUISettings[0].type === 'everybody'
+  const anyoneCanQuote =
+    !postgate.embeddingRules || postgate.embeddingRules.length === 0
+  const anyoneCanInteract = anyoneCanReply && anyoneCanQuote
+  const label = anyoneCanInteract
+    ? _(msg`Anybody can interact`)
+    : _(msg`Interaction limited`)
 
   return (
     <>
@@ -59,16 +67,19 @@ export function ThreadgateBtn({
           accessibilityHint={_(
             msg`Opens a dialog to choose who can reply to this thread`,
           )}>
-          <ButtonIcon
-            icon={isEverybody ? Earth : isNobody ? CircleBanSign : Group}
-          />
+          <ButtonIcon icon={anyoneCanInteract ? Earth : Group} />
           <ButtonText>{label}</ButtonText>
         </Button>
       </Animated.View>
-      <ThreadgateEditorDialog
+      <PostInteractionSettingsControlledDialog
         control={control}
-        threadgate={threadgate}
-        onChange={onChange}
+        onSave={() => {
+          control.close()
+        }}
+        postgate={postgate}
+        onChangePostgate={onChangePostgate}
+        threadgateAllowUISettings={threadgateAllowUISettings}
+        onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
       />
     </>
   )
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index 2938ea25a..317514437 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -1,4 +1,6 @@
 import {useEffect, useState} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 import {logger} from '#/logger'
 import {useFetchDid} from '#/state/queries/handle'
@@ -7,6 +9,7 @@ import {useAgent} from '#/state/session'
 import * as apilib from 'lib/api/index'
 import {POST_IMG_MAX} from 'lib/constants'
 import {
+  EmbeddingDisabledError,
   getFeedAsEmbed,
   getListAsEmbed,
   getPostAsQuote,
@@ -28,9 +31,12 @@ import {ComposerOpts} from 'state/shell/composer'
 
 export function useExternalLinkFetch({
   setQuote,
+  setError,
 }: {
   setQuote: (opts: ComposerOpts['quote']) => void
+  setError: (err: string) => void
 }) {
+  const {_} = useLingui()
   const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
     undefined,
   )
@@ -57,9 +63,13 @@ export function useExternalLinkFetch({
             setExtLink(undefined)
           },
           err => {
-            logger.error('Failed to fetch post for quote embedding', {
-              message: err.toString(),
-            })
+            if (err instanceof EmbeddingDisabledError) {
+              setError(_(msg`This post's author has disabled quote posts.`))
+            } else {
+              logger.error('Failed to fetch post for quote embedding', {
+                message: err.toString(),
+              })
+            }
             setExtLink(undefined)
           },
         )
@@ -170,7 +180,7 @@ export function useExternalLinkFetch({
       })
     }
     return cleanup
-  }, [extLink, setQuote, getPost, fetchDid, agent])
+  }, [_, extLink, setQuote, getPost, fetchDid, agent, setError])
 
   return {extLink, setExtLink}
 }
diff --git a/src/view/com/post-thread/PostQuotes.tsx b/src/view/com/post-thread/PostQuotes.tsx
index d573d27a1..f91a041d7 100644
--- a/src/view/com/post-thread/PostQuotes.tsx
+++ b/src/view/com/post-thread/PostQuotes.tsx
@@ -10,7 +10,6 @@ import {useLingui} from '@lingui/react'
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
-import {isWeb} from '#/platform/detection'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {usePostQuotesQuery} from '#/state/queries/post-quotes'
 import {useResolveUriQuery} from '#/state/queries/resolve-uri'
@@ -25,16 +24,14 @@ import {List} from '../util/List'
 
 function renderItem({
   item,
-  index,
 }: {
   item: {
     post: AppBskyFeedDefs.PostView
     moderation: ModerationDecision
     record: AppBskyFeedPost.Record
   }
-  index: number
 }) {
-  return <Post post={item.post} hideTopBorder={index === 0 && !isWeb} />
+  return <Post post={item.post} />
 }
 
 function keyExtractor(item: {
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index c64be8d67..bd778fd98 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -3,7 +3,12 @@ import {StyleSheet, useWindowDimensions, View} from 'react-native'
 import {runOnJS} from 'react-native-reanimated'
 import Animated from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {AppBskyFeedDefs} from '@atproto/api'
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AppBskyFeedThreadgate,
+  AtUri,
+} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -23,6 +28,7 @@ import {
   usePostThreadQuery,
 } from '#/state/queries/post-thread'
 import {usePreferencesQuery} from '#/state/queries/preferences'
+import {useThreadgateRecordQuery} from '#/state/queries/threadgate'
 import {useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell'
 import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
@@ -113,6 +119,28 @@ export function PostThread({uri}: {uri: string | undefined}) {
   )
   const rootPost = thread?.type === 'post' ? thread.post : undefined
   const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
+  const replyRef =
+    rootPostRecord && AppBskyFeedPost.isRecord(rootPostRecord)
+      ? rootPostRecord.reply
+      : undefined
+  const rootPostUri = replyRef ? replyRef.root.uri : rootPost?.uri
+
+  const isOP =
+    currentAccount &&
+    rootPostUri &&
+    currentAccount?.did === new AtUri(rootPostUri).host
+  const {data: threadgateRecord} = useThreadgateRecordQuery({
+    /**
+     * If the user is the OP and the root post has a threadgate, we should load
+     * the threadgate record. Otherwise, fallback to initialData, which is taken
+     * from the response from `getPostThread`.
+     */
+    enabled: Boolean(isOP && rootPostUri),
+    postUri: rootPostUri,
+    initialData: rootPost?.threadgate?.record as
+      | AppBskyFeedThreadgate.Record
+      | undefined,
+  })
 
   const moderationOpts = useModerationOpts()
   const isNoPwi = React.useMemo(() => {
@@ -167,6 +195,9 @@ export function PostThread({uri}: {uri: string | undefined}) {
   const skeleton = React.useMemo(() => {
     const threadViewPrefs = preferences?.threadViewPrefs
     if (!threadViewPrefs || !thread) return null
+    const threadgateRecordHiddenReplies = new Set<string>(
+      threadgateRecord?.hiddenReplies || [],
+    )
 
     return createThreadSkeleton(
       sortThread(
@@ -175,11 +206,13 @@ export function PostThread({uri}: {uri: string | undefined}) {
         threadModerationCache,
         currentDid,
         justPostedUris,
+        threadgateRecordHiddenReplies,
       ),
-      !!currentDid,
+      currentDid,
       treeView,
       threadModerationCache,
       hiddenRepliesState !== HiddenRepliesState.Hide,
+      threadgateRecordHiddenReplies,
     )
   }, [
     thread,
@@ -189,6 +222,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
     threadModerationCache,
     hiddenRepliesState,
     justPostedUris,
+    threadgateRecord,
   ])
 
   const error = React.useMemo(() => {
@@ -425,6 +459,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
           <PostThreadItem
             post={item.post}
             record={item.record}
+            threadgateRecord={threadgateRecord ?? undefined}
             moderation={threadModerationCache.get(item)}
             treeView={treeView}
             depth={item.ctx.depth}
@@ -545,23 +580,25 @@ function isThreadBlocked(v: unknown): v is ThreadBlocked {
 
 function createThreadSkeleton(
   node: ThreadNode,
-  hasSession: boolean,
+  currentDid: string | undefined,
   treeView: boolean,
   modCache: ThreadModerationCache,
   showHiddenReplies: boolean,
+  threadgateRecordHiddenReplies: Set<string>,
 ): ThreadSkeletonParts | null {
   if (!node) return null
 
   return {
-    parents: Array.from(flattenThreadParents(node, hasSession)),
+    parents: Array.from(flattenThreadParents(node, !!currentDid)),
     highlightedPost: node,
     replies: Array.from(
       flattenThreadReplies(
         node,
-        hasSession,
+        currentDid,
         treeView,
         modCache,
         showHiddenReplies,
+        threadgateRecordHiddenReplies,
       ),
     ),
   }
@@ -594,14 +631,15 @@ enum HiddenReplyType {
 
 function* flattenThreadReplies(
   node: ThreadNode,
-  hasSession: boolean,
+  currentDid: string | undefined,
   treeView: boolean,
   modCache: ThreadModerationCache,
   showHiddenReplies: boolean,
+  threadgateRecordHiddenReplies: Set<string>,
 ): Generator<YieldedItem, HiddenReplyType> {
   if (node.type === 'post') {
     // dont show pwi-opted-out posts to logged out users
-    if (!hasSession && hasPwiOptOut(node)) {
+    if (!currentDid && hasPwiOptOut(node)) {
       return HiddenReplyType.None
     }
 
@@ -616,6 +654,16 @@ function* flattenThreadReplies(
           return HiddenReplyType.Hidden
         }
       }
+
+      if (!showHiddenReplies) {
+        const hiddenByThreadgate = threadgateRecordHiddenReplies.has(
+          node.post.uri,
+        )
+        const authorIsViewer = node.post.author.did === currentDid
+        if (hiddenByThreadgate && !authorIsViewer) {
+          return HiddenReplyType.Hidden
+        }
+      }
     }
 
     if (!node.ctx.isHighlightedPost) {
@@ -627,10 +675,11 @@ function* flattenThreadReplies(
       for (const reply of node.replies) {
         let hiddenReply = yield* flattenThreadReplies(
           reply,
-          hasSession,
+          currentDid,
           treeView,
           modCache,
           showHiddenReplies,
+          threadgateRecordHiddenReplies,
         )
         if (hiddenReply > hiddenReplies) {
           hiddenReplies = hiddenReply
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 26a5f2f03..da187f5d9 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'
 import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
+  AppBskyFeedThreadgate,
   AtUri,
   ModerationDecision,
   RichText as RichTextAPI,
@@ -29,6 +30,7 @@ import {isWeb} from 'platform/detection'
 import {useSession} from 'state/session'
 import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
 import {atoms as a} from '#/alf'
+import {AppModerationCause} from '#/components/Pills'
 import {RichText} from '#/components/RichText'
 import {ContentHider} from '../../../components/moderation/ContentHider'
 import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
@@ -61,6 +63,7 @@ export function PostThreadItem({
   overrideBlur,
   onPostReply,
   hideTopBorder,
+  threadgateRecord,
 }: {
   post: AppBskyFeedDefs.PostView
   record: AppBskyFeedPost.Record
@@ -77,6 +80,7 @@ export function PostThreadItem({
   overrideBlur: boolean
   onPostReply: (postUri: string | undefined) => void
   hideTopBorder?: boolean
+  threadgateRecord?: AppBskyFeedThreadgate.Record
 }) {
   const postShadowed = usePostShadow(post)
   const richText = useMemo(
@@ -111,6 +115,7 @@ export function PostThreadItem({
         overrideBlur={overrideBlur}
         onPostReply={onPostReply}
         hideTopBorder={hideTopBorder}
+        threadgateRecord={threadgateRecord}
       />
     )
   }
@@ -154,6 +159,7 @@ let PostThreadItemLoaded = ({
   overrideBlur,
   onPostReply,
   hideTopBorder,
+  threadgateRecord,
 }: {
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
@@ -171,6 +177,7 @@ let PostThreadItemLoaded = ({
   overrideBlur: boolean
   onPostReply: (postUri: string | undefined) => void
   hideTopBorder?: boolean
+  threadgateRecord?: AppBskyFeedThreadgate.Record
 }): React.ReactNode => {
   const pal = usePalette('default')
   const {_} = useLingui()
@@ -199,6 +206,24 @@ let PostThreadItemLoaded = ({
     return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
   }, [post.uri, post.author])
   const repostsTitle = _(msg`Reposts of this post`)
+  const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
+    const isPostHiddenByThreadgate = threadgateRecord?.hiddenReplies?.includes(
+      post.uri,
+    )
+    const isControlledByViewer =
+      threadgateRecord &&
+      new AtUri(threadgateRecord.post).host === currentAccount?.did
+    if (!isControlledByViewer) return []
+    return threadgateRecord && isPostHiddenByThreadgate
+      ? [
+          {
+            type: 'reply-hidden',
+            source: {type: 'user', did: new AtUri(threadgateRecord.post).host},
+            priority: 6,
+          },
+        ]
+      : []
+  }, [post, threadgateRecord, currentAccount?.did])
   const quotesHref = React.useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
@@ -320,6 +345,7 @@ let PostThreadItemLoaded = ({
                 size="lg"
                 includeMute
                 style={[a.pt_2xs, a.pb_sm]}
+                additionalCauses={additionalPostAlerts}
               />
               {richText?.text ? (
                 <View
@@ -420,6 +446,7 @@ let PostThreadItemLoaded = ({
                 onPressReply={onPressReply}
                 onPostReply={onPostReply}
                 logContext="PostThreadItem"
+                threadgateRecord={threadgateRecord}
               />
             </View>
           </View>
@@ -540,6 +567,7 @@ let PostThreadItemLoaded = ({
               <PostAlerts
                 modui={moderation.ui('contentList')}
                 style={[a.pt_2xs, a.pb_2xs]}
+                additionalCauses={additionalPostAlerts}
               />
               {richText?.text ? (
                 <View style={styles.postTextContainer}>
@@ -571,6 +599,7 @@ let PostThreadItemLoaded = ({
                 richText={richText}
                 onPressReply={onPressReply}
                 logContext="PostThreadItem"
+                threadgateRecord={threadgateRecord}
               />
             </View>
           </View>
@@ -677,6 +706,7 @@ function ExpandedPostDetails({
   const pal = usePalette('default')
   const {_} = useLingui()
   const openLink = useOpenLink()
+  const isRootPost = !('reply' in post.record)
 
   const onTranslatePress = React.useCallback(() => {
     openLink(translatorUrl)
@@ -693,7 +723,9 @@ function ExpandedPostDetails({
         s.mb10,
       ]}>
       <Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text>
-      <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
+      {isRootPost && (
+        <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
+      )}
       {needsTranslation && (
         <>
           <Text style={[a.text_sm, pal.textLight]}>&middot;</Text>
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 0fef4c5a8..e90e8b885 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -4,6 +4,7 @@ import {
   AppBskyActorDefs,
   AppBskyFeedDefs,
   AppBskyFeedPost,
+  AppBskyFeedThreadgate,
   AtUri,
   ModerationDecision,
   RichText as RichTextAPI,
@@ -21,6 +22,7 @@ import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
 import {useFeedFeedbackContext} from '#/state/feed-feedback'
 import {useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
+import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
 import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
 import {MAX_POST_LINES} from 'lib/constants'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -33,6 +35,7 @@ import {precacheProfile} from 'state/queries/profile'
 import {atoms as a} from '#/alf'
 import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
 import {ContentHider} from '#/components/moderation/ContentHider'
+import {AppModerationCause} from '#/components/Pills'
 import {ProfileHoverCard} from '#/components/ProfileHoverCard'
 import {RichText} from '#/components/RichText'
 import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
@@ -80,7 +83,11 @@ export function FeedItem({
   hideTopBorder,
   isParentBlocked,
   isParentNotFound,
-}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
+  rootPost,
+}: FeedItemProps & {
+  post: AppBskyFeedDefs.PostView
+  rootPost: AppBskyFeedDefs.PostView
+}): React.ReactNode {
   const postShadowed = usePostShadow(post)
   const richText = useMemo(
     () =>
@@ -112,6 +119,7 @@ export function FeedItem({
         hideTopBorder={hideTopBorder}
         isParentBlocked={isParentBlocked}
         isParentNotFound={isParentNotFound}
+        rootPost={rootPost}
       />
     )
   }
@@ -133,9 +141,11 @@ let FeedItemInner = ({
   hideTopBorder,
   isParentBlocked,
   isParentNotFound,
+  rootPost,
 }: FeedItemProps & {
   richText: RichTextAPI
   post: Shadow<AppBskyFeedDefs.PostView>
+  rootPost: AppBskyFeedDefs.PostView
 }): React.ReactNode => {
   const queryClient = useQueryClient()
   const {openComposer} = useComposerControls()
@@ -217,6 +227,12 @@ let FeedItemInner = ({
     AppBskyFeedDefs.isReasonRepost(reason) &&
     reason.by.did === currentAccount?.did
 
+  const threadgateRecord = AppBskyFeedThreadgate.isRecord(
+    rootPost.threadgate?.record,
+  )
+    ? rootPost.threadgate.record
+    : undefined
+
   return (
     <Link
       testID={`feedItem-by-${post.author.handle}`}
@@ -363,6 +379,8 @@ let FeedItemInner = ({
             postEmbed={post.embed}
             postAuthor={post.author}
             onOpenEmbed={onOpenEmbed}
+            post={post}
+            threadgateRecord={threadgateRecord}
           />
           <VideoDebug />
           <PostCtrls
@@ -372,6 +390,7 @@ let FeedItemInner = ({
             onPressReply={onPressReply}
             logContext="FeedItem"
             feedContext={feedContext}
+            threadgateRecord={threadgateRecord}
           />
         </View>
       </View>
@@ -381,23 +400,63 @@ let FeedItemInner = ({
 FeedItemInner = memo(FeedItemInner)
 
 let PostContent = ({
+  post,
   moderation,
   richText,
   postEmbed,
   postAuthor,
   onOpenEmbed,
+  threadgateRecord,
 }: {
   moderation: ModerationDecision
   richText: RichTextAPI
   postEmbed: AppBskyFeedDefs.PostView['embed']
   postAuthor: AppBskyFeedDefs.PostView['author']
   onOpenEmbed: () => void
+  post: AppBskyFeedDefs.PostView
+  threadgateRecord?: AppBskyFeedThreadgate.Record
 }): React.ReactNode => {
   const pal = usePalette('default')
   const {_} = useLingui()
+  const {currentAccount} = useSession()
   const [limitLines, setLimitLines] = useState(
     () => countLines(richText.text) >= MAX_POST_LINES,
   )
+  const {uris: hiddenReplyUris, recentlyUnhiddenUris} =
+    useThreadgateHiddenReplyUris()
+  const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
+    const isPostHiddenByHiddenReplyCache = hiddenReplyUris.has(post.uri)
+    const isPostHiddenByThreadgate =
+      !recentlyUnhiddenUris.has(post.uri) &&
+      !!threadgateRecord?.hiddenReplies?.includes(post.uri)
+    const isHidden = isPostHiddenByHiddenReplyCache || isPostHiddenByThreadgate
+    const isControlledByViewer =
+      isPostHiddenByHiddenReplyCache ||
+      (threadgateRecord &&
+        new AtUri(threadgateRecord.post).host === currentAccount?.did)
+    if (!isControlledByViewer) return []
+    const alertSource =
+      threadgateRecord && isPostHiddenByThreadgate
+        ? new AtUri(threadgateRecord.post).host
+        : isPostHiddenByHiddenReplyCache
+        ? currentAccount?.did
+        : undefined
+    return isHidden && alertSource
+      ? [
+          {
+            type: 'reply-hidden',
+            source: {type: 'user', did: alertSource},
+            priority: 6,
+          },
+        ]
+      : []
+  }, [
+    post,
+    hiddenReplyUris,
+    recentlyUnhiddenUris,
+    threadgateRecord,
+    currentAccount?.did,
+  ])
 
   const onPressShowMore = React.useCallback(() => {
     setLimitLines(false)
@@ -409,7 +468,11 @@ let PostContent = ({
       modui={moderation.ui('contentList')}
       ignoreMute
       childContainerStyle={styles.contentHiderChild}>
-      <PostAlerts modui={moderation.ui('contentList')} style={[a.py_2xs]} />
+      <PostAlerts
+        modui={moderation.ui('contentList')}
+        style={[a.py_2xs]}
+        additionalCauses={additionalPostAlerts}
+      />
       {richText.text ? (
         <View style={styles.postTextContainer}>
           <RichText
@@ -460,7 +523,7 @@ function ReplyToLabel({
   if (blocked) {
     label = <Trans context="description">Reply to a blocked post</Trans>
   } else if (notFound) {
-    label = <Trans context="description">Reply to an unknown post</Trans>
+    label = <Trans context="description">Reply to a post</Trans>
   } else if (profile != null) {
     const isMe = profile.did === currentAccount?.did
     if (isMe) {
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index 9676eff1f..0920026f6 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -37,6 +37,7 @@ let FeedSlice = ({
           hideTopBorder={hideTopBorder}
           isParentBlocked={slice.items[0].isParentBlocked}
           isParentNotFound={slice.items[0].isParentNotFound}
+          rootPost={slice.items[0].post}
         />
         <ViewFullThread uri={slice.items[0].uri} />
         <FeedItem
@@ -55,6 +56,7 @@ let FeedSlice = ({
           isThreadChild={isThreadChildAt(slice.items, beforeLast)}
           isParentBlocked={slice.items[beforeLast].isParentBlocked}
           isParentNotFound={slice.items[beforeLast].isParentNotFound}
+          rootPost={slice.items[0].post}
         />
         <FeedItem
           key={slice.items[last]._reactKey}
@@ -70,6 +72,7 @@ let FeedSlice = ({
           isParentBlocked={slice.items[last].isParentBlocked}
           isParentNotFound={slice.items[last].isParentNotFound}
           isThreadLastChild
+          rootPost={slice.items[0].post}
         />
       </>
     )
@@ -95,6 +98,7 @@ let FeedSlice = ({
           isParentBlocked={slice.items[i].isParentBlocked}
           isParentNotFound={slice.items[i].isParentNotFound}
           hideTopBorder={hideTopBorder && i === 0}
+          rootPost={slice.items[0].post}
         />
       ))}
     </>
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 6c82ec8cc..b293b0dff 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,5 +1,6 @@
 import React, {memo} from 'react'
 import {
+  Platform,
   Pressable,
   type PressableProps,
   type StyleProp,
@@ -9,6 +10,7 @@ import * as Clipboard from 'expo-clipboard'
 import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
+  AppBskyFeedThreadgate,
   AtUri,
   RichText as RichTextAPI,
 } from '@atproto/api'
@@ -31,7 +33,11 @@ import {
   usePostDeleteMutation,
   useThreadMuteMutationQueue,
 } from '#/state/queries/post'
+import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate'
+import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util'
+import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
 import {useSession} from '#/state/session'
+import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
 import {getCurrentRoute} from 'lib/routes/helpers'
 import {shareUrl} from 'lib/sharing'
 import {toShareUrl} from 'lib/strings/url-helpers'
@@ -40,6 +46,10 @@ import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf'
 import {useDialogControl} from '#/components/Dialog'
 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 import {EmbedDialog} from '#/components/dialogs/Embed'
+import {
+  PostInteractionSettingsDialog,
+  usePrefetchPostInteractionSettings,
+} from '#/components/dialogs/PostInteractionSettingsDialog'
 import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
 import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
@@ -50,13 +60,16 @@ import {
   EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
   EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
 } from '#/components/icons/Emoji'
+import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye'
 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
 import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
+import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {Loader} from '#/components/Loader'
 import * as Menu from '#/components/Menu'
 import * as Prompt from '#/components/Prompt'
 import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
@@ -73,6 +86,7 @@ let PostDropdownBtn = ({
   hitSlop,
   size,
   timestamp,
+  threadgateRecord,
 }: {
   testID: string
   post: Shadow<AppBskyFeedDefs.PostView>
@@ -83,6 +97,7 @@ let PostDropdownBtn = ({
   hitSlop?: PressableProps['hitSlop']
   size?: 'lg' | 'md' | 'sm'
   timestamp: string
+  threadgateRecord?: AppBskyFeedThreadgate.Record
 }): React.ReactNode => {
   const {hasSession, currentAccount} = useSession()
   const theme = useTheme()
@@ -104,17 +119,46 @@ let PostDropdownBtn = ({
   const loggedOutWarningPromptControl = useDialogControl()
   const embedPostControl = useDialogControl()
   const sendViaChatControl = useDialogControl()
+  const postInteractionSettingsDialogControl = useDialogControl()
+  const quotePostDetachConfirmControl = useDialogControl()
+  const hideReplyConfirmControl = useDialogControl()
+  const {mutateAsync: toggleReplyVisibility} =
+    useToggleReplyVisibilityMutation()
+  const {uris: hiddenReplies, recentlyUnhiddenUris} =
+    useThreadgateHiddenReplyUris()
+
   const postUri = post.uri
   const postCid = post.cid
   const postAuthor = post.author
+  const quoteEmbed = React.useMemo(() => {
+    if (!currentAccount || !post.embed) return
+    return getMaybeDetachedQuoteEmbed({
+      viewerDid: currentAccount.did,
+      post,
+    })
+  }, [post, currentAccount])
 
   const rootUri = record.reply?.root?.uri || postUri
+  const isReply = Boolean(record.reply)
   const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
     post,
     rootUri,
   )
   const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
   const isAuthor = postAuthor.did === currentAccount?.did
+  const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did
+  const isReplyHiddenByThreadgate =
+    hiddenReplies.has(postUri) ||
+    (!recentlyUnhiddenUris.has(postUri) &&
+      threadgateRecord?.hiddenReplies?.includes(postUri))
+
+  const {mutateAsync: toggleQuoteDetachment, isPending} =
+    useToggleQuoteDetachmentMutation()
+
+  const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
+    postUri: post.uri,
+    rootPostUri: rootUri,
+  })
 
   const href = React.useMemo(() => {
     const urip = new AtUri(postUri)
@@ -242,7 +286,65 @@ let PostDropdownBtn = ({
     [navigation, postUri],
   )
 
+  const onToggleQuotePostAttachment = React.useCallback(async () => {
+    if (!quoteEmbed) return
+
+    const action = quoteEmbed.isDetached ? 'reattach' : 'detach'
+    const isDetach = action === 'detach'
+
+    try {
+      await toggleQuoteDetachment({
+        post,
+        quoteUri: quoteEmbed.uri,
+        action: quoteEmbed.isDetached ? 'reattach' : 'detach',
+      })
+      Toast.show(
+        isDetach
+          ? _(msg`Quote post was successfully detached`)
+          : _(msg`Quote post was re-attached`),
+      )
+    } catch (e: any) {
+      Toast.show(_(msg`Updating quote attachment failed`))
+      logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
+    }
+  }, [_, quoteEmbed, post, toggleQuoteDetachment])
+
+  const canHidePostForMe = !isAuthor && !isPostHidden
   const canEmbed = isWeb && gtMobile && !hideInPWI
+  const canHideReplyForEveryone =
+    !isAuthor && isRootPostAuthor && !isPostHidden && isReply
+  const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
+
+  const onToggleReplyVisibility = React.useCallback(async () => {
+    // TODO no threadgate?
+    if (!canHideReplyForEveryone) return
+
+    const action = isReplyHiddenByThreadgate ? 'show' : 'hide'
+    const isHide = action === 'hide'
+
+    try {
+      await toggleReplyVisibility({
+        postUri: rootUri,
+        replyUri: postUri,
+        action,
+      })
+      Toast.show(
+        isHide
+          ? _(msg`Reply was successfully hidden`)
+          : _(msg`Reply visibility updated`),
+      )
+    } catch (e: any) {
+      Toast.show(_(msg`Updating reply visibility failed`))
+      logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
+    }
+  }, [
+    _,
+    isReplyHiddenByThreadgate,
+    rootUri,
+    postUri,
+    canHideReplyForEveryone,
+    toggleReplyVisibility,
+  ])
 
   return (
     <EventStopper onKeyDown={false}>
@@ -383,20 +485,92 @@ let PostDropdownBtn = ({
                   <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
                   <Menu.ItemIcon icon={Filter} position="right" />
                 </Menu.Item>
-
-                {!isAuthor && !isPostHidden && (
-                  <Menu.Item
-                    testID="postDropdownHideBtn"
-                    label={_(msg`Hide post`)}
-                    onPress={hidePromptControl.open}>
-                    <Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText>
-                    <Menu.ItemIcon icon={EyeSlash} position="right" />
-                  </Menu.Item>
-                )}
               </Menu.Group>
             </>
           )}
 
+          {hasSession &&
+            (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
+              <>
+                <Menu.Divider />
+                <Menu.Group>
+                  {canHidePostForMe && (
+                    <Menu.Item
+                      testID="postDropdownHideBtn"
+                      label={
+                        isReply
+                          ? _(msg`Hide reply for me`)
+                          : _(msg`Hide post for me`)
+                      }
+                      onPress={hidePromptControl.open}>
+                      <Menu.ItemText>
+                        {isReply
+                          ? _(msg`Hide reply for me`)
+                          : _(msg`Hide post for me`)}
+                      </Menu.ItemText>
+                      <Menu.ItemIcon icon={EyeSlash} position="right" />
+                    </Menu.Item>
+                  )}
+                  {canHideReplyForEveryone && (
+                    <Menu.Item
+                      testID="postDropdownHideBtn"
+                      label={
+                        isReplyHiddenByThreadgate
+                          ? _(msg`Show reply for everyone`)
+                          : _(msg`Hide reply for everyone`)
+                      }
+                      onPress={
+                        isReplyHiddenByThreadgate
+                          ? onToggleReplyVisibility
+                          : () => hideReplyConfirmControl.open()
+                      }>
+                      <Menu.ItemText>
+                        {isReplyHiddenByThreadgate
+                          ? _(msg`Show reply for everyone`)
+                          : _(msg`Hide reply for everyone`)}
+                      </Menu.ItemText>
+                      <Menu.ItemIcon
+                        icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
+                        position="right"
+                      />
+                    </Menu.Item>
+                  )}
+
+                  {canDetachQuote && (
+                    <Menu.Item
+                      disabled={isPending}
+                      testID="postDropdownHideBtn"
+                      label={
+                        quoteEmbed.isDetached
+                          ? _(msg`Re-attach quote`)
+                          : _(msg`Detach quote`)
+                      }
+                      onPress={
+                        quoteEmbed.isDetached
+                          ? onToggleQuotePostAttachment
+                          : () => quotePostDetachConfirmControl.open()
+                      }>
+                      <Menu.ItemText>
+                        {quoteEmbed.isDetached
+                          ? _(msg`Re-attach quote`)
+                          : _(msg`Detach quote`)}
+                      </Menu.ItemText>
+                      <Menu.ItemIcon
+                        icon={
+                          isPending
+                            ? Loader
+                            : quoteEmbed.isDetached
+                            ? Eye
+                            : EyeSlash
+                        }
+                        position="right"
+                      />
+                    </Menu.Item>
+                  )}
+                </Menu.Group>
+              </>
+            )}
+
           {hasSession && (
             <>
               <Menu.Divider />
@@ -412,13 +586,34 @@ let PostDropdownBtn = ({
                 )}
 
                 {isAuthor && (
-                  <Menu.Item
-                    testID="postDropdownDeleteBtn"
-                    label={_(msg`Delete post`)}
-                    onPress={deletePromptControl.open}>
-                    <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
-                    <Menu.ItemIcon icon={Trash} position="right" />
-                  </Menu.Item>
+                  <>
+                    <Menu.Item
+                      testID="postDropdownEditPostInteractions"
+                      label={_(msg`Edit interaction settings`)}
+                      onPress={postInteractionSettingsDialogControl.open}
+                      {...(isAuthor
+                        ? Platform.select({
+                            web: {
+                              onHoverIn: prefetchPostInteractionSettings,
+                            },
+                            native: {
+                              onPressIn: prefetchPostInteractionSettings,
+                            },
+                          })
+                        : {})}>
+                      <Menu.ItemText>
+                        {_(msg`Edit interaction settings`)}
+                      </Menu.ItemText>
+                      <Menu.ItemIcon icon={Gear} position="right" />
+                    </Menu.Item>
+                    <Menu.Item
+                      testID="postDropdownDeleteBtn"
+                      label={_(msg`Delete post`)}
+                      onPress={deletePromptControl.open}>
+                      <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
+                      <Menu.ItemIcon icon={Trash} position="right" />
+                    </Menu.Item>
+                  </>
                 )}
               </Menu.Group>
             </>
@@ -439,8 +634,10 @@ let PostDropdownBtn = ({
 
       <Prompt.Basic
         control={hidePromptControl}
-        title={_(msg`Hide this post?`)}
-        description={_(msg`This post will be hidden from feeds.`)}
+        title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
+        description={_(
+          msg`This post will be hidden from feeds and threads. This cannot be undone.`,
+        )}
         onConfirm={onHidePost}
         confirmButtonCta={_(msg`Hide`)}
       />
@@ -479,6 +676,33 @@ let PostDropdownBtn = ({
         control={sendViaChatControl}
         onSelectChat={onSelectChatToShareTo}
       />
+
+      <PostInteractionSettingsDialog
+        control={postInteractionSettingsDialogControl}
+        postUri={post.uri}
+        rootPostUri={rootUri}
+        initialThreadgateView={post.threadgate}
+      />
+
+      <Prompt.Basic
+        control={quotePostDetachConfirmControl}
+        title={_(msg`Detach quote post?`)}
+        description={_(
+          msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
+        )}
+        onConfirm={onToggleQuotePostAttachment}
+        confirmButtonCta={_(msg`Yes, detach`)}
+      />
+
+      <Prompt.Basic
+        control={hideReplyConfirmControl}
+        title={_(msg`Hide this reply?`)}
+        description={_(
+          msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`,
+        )}
+        onConfirm={onToggleReplyVisibility}
+        confirmButtonCta={_(msg`Yes, hide`)}
+      />
     </EventStopper>
   )
 }
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index ad5863846..0cfa3fc4d 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -10,6 +10,7 @@ import * as Clipboard from 'expo-clipboard'
 import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
+  AppBskyFeedThreadgate,
   AtUri,
   RichText as RichTextAPI,
 } from '@atproto/api'
@@ -60,6 +61,7 @@ let PostCtrls = ({
   onPressReply,
   onPostReply,
   logContext,
+  threadgateRecord,
 }: {
   big?: boolean
   post: Shadow<AppBskyFeedDefs.PostView>
@@ -70,6 +72,7 @@ let PostCtrls = ({
   onPressReply: () => void
   onPostReply?: (postUri: string | undefined) => void
   logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  threadgateRecord?: AppBskyFeedThreadgate.Record
 }): React.ReactNode => {
   const t = useTheme()
   const {_} = useLingui()
@@ -256,6 +259,7 @@ let PostCtrls = ({
           onRepost={onRepost}
           onQuote={onQuote}
           big={big}
+          embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
         />
       </View>
       <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
@@ -344,6 +348,7 @@ let PostCtrls = ({
           style={{padding: 5}}
           hitSlop={POST_CTRL_HITSLOP}
           timestamp={post.indexedAt}
+          threadgateRecord={threadgateRecord}
         />
       </View>
       {gate('debug_show_feedcontext') && feedContext && (
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index d49cda442..5994b7ef6 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -20,6 +20,7 @@ interface Props {
   onRepost: () => void
   onQuote: () => void
   big?: boolean
+  embeddingDisabled: boolean
 }
 
 let RepostButton = ({
@@ -28,6 +29,7 @@ let RepostButton = ({
   onRepost,
   onQuote,
   big,
+  embeddingDisabled,
 }: Props): React.ReactNode => {
   const t = useTheme()
   const {_} = useLingui()
@@ -111,9 +113,14 @@ let RepostButton = ({
                 </Text>
               </Button>
               <Button
+                disabled={embeddingDisabled}
                 testID="quoteBtn"
                 style={[a.justify_start, a.px_md]}
-                label={_(msg`Quote post`)}
+                label={
+                  embeddingDisabled
+                    ? _(msg`Quote posts disabled`)
+                    : _(msg`Quote post`)
+                }
                 onPress={() => {
                   playHaptic()
                   dialogControl.close(() => {
@@ -123,9 +130,23 @@ let RepostButton = ({
                 size="large"
                 variant="ghost"
                 color="primary">
-                <Quote size="lg" fill={t.palette.primary_500} />
-                <Text style={[a.font_bold, a.text_xl]}>
-                  {_(msg`Quote post`)}
+                <Quote
+                  size="lg"
+                  fill={
+                    embeddingDisabled
+                      ? t.atoms.text_contrast_low.color
+                      : t.palette.primary_500
+                  }
+                />
+                <Text
+                  style={[
+                    a.font_bold,
+                    a.text_xl,
+                    embeddingDisabled && t.atoms.text_contrast_low,
+                  ]}>
+                  {embeddingDisabled
+                    ? _(msg`Quote posts disabled`)
+                    : _(msg`Quote post`)}
                 </Text>
               </Button>
             </View>
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx
index 17ab736ce..9a8776b9c 100644
--- a/src/view/com/util/post-ctrls/RepostButton.web.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx
@@ -20,6 +20,7 @@ interface Props {
   onRepost: () => void
   onQuote: () => void
   big?: boolean
+  embeddingDisabled: boolean
 }
 
 export const RepostButton = ({
@@ -28,6 +29,7 @@ export const RepostButton = ({
   onRepost,
   onQuote,
   big,
+  embeddingDisabled,
 }: Props) => {
   const t = useTheme()
   const {_} = useLingui()
@@ -76,10 +78,19 @@ export const RepostButton = ({
             <Menu.ItemIcon icon={Repost} position="right" />
           </Menu.Item>
           <Menu.Item
-            label={_(msg`Quote post`)}
+            disabled={embeddingDisabled}
+            label={
+              embeddingDisabled
+                ? _(msg`Quote posts disabled`)
+                : _(msg`Quote post`)
+            }
             testID="repostDropdownQuoteBtn"
             onPress={onQuote}>
-            <Menu.ItemText>{_(msg`Quote post`)}</Menu.ItemText>
+            <Menu.ItemText>
+              {embeddingDisabled
+                ? _(msg`Quote posts disabled`)
+                : _(msg`Quote post`)}
+            </Menu.ItemText>
             <Menu.ItemIcon icon={Quote} position="right" />
           </Menu.Item>
         </Menu.Outer>
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 20c05b692..192aea708 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -26,6 +26,7 @@ import {useQueryClient} from '@tanstack/react-query'
 import {HITSLOP_20} from '#/lib/constants'
 import {s} from '#/lib/styles'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useSession} from '#/state/session'
 import {usePalette} from 'lib/hooks/usePalette'
 import {InfoCircleIcon} from 'lib/icons'
 import {makeProfileLink} from 'lib/routes/links'
@@ -52,6 +53,7 @@ export function MaybeQuoteEmbed({
   allowNestedQuotes?: boolean
 }) {
   const pal = usePalette('default')
+  const {currentAccount} = useSession()
   if (
     AppBskyEmbedRecord.isViewRecord(embed.record) &&
     AppBskyFeedPost.isRecord(embed.record.value) &&
@@ -84,6 +86,22 @@ export function MaybeQuoteEmbed({
         </Text>
       </View>
     )
+  } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) {
+    const isViewerOwner = currentAccount?.did
+      ? embed.record.uri.includes(currentAccount.did)
+      : false
+    return (
+      <View style={[styles.errorContainer, pal.borderDark]}>
+        <InfoCircleIcon size={18} style={pal.text} />
+        <Text type="lg" style={pal.text}>
+          {isViewerOwner ? (
+            <Trans>Removed by you</Trans>
+          ) : (
+            <Trans>Removed by author</Trans>
+          )}
+        </Text>
+      </View>
+    )
   }
   return null
 }
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
index 7d0d2fb03..9c609348e 100644
--- a/src/view/screens/DebugMod.tsx
+++ b/src/view/screens/DebugMod.tsx
@@ -807,6 +807,7 @@ function MockPostFeedItem({
       showReplyTo={false}
       reason={undefined}
       feedContext={''}
+      rootPost={post}
     />
   )
 }
diff --git a/yarn.lock b/yarn.lock
index 995c548b7..da842c893 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -72,10 +72,10 @@
   resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
   integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
 
-"@atproto/api@^0.13.0":
-  version "0.13.0"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8"
-  integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA==
+"@atproto/api@0.13.2":
+  version "0.13.2"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863"
+  integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw==
   dependencies:
     "@atproto/common-web" "^0.3.0"
     "@atproto/lexicon" "^0.4.1"
@@ -85,10 +85,10 @@
     multiformats "^9.9.0"
     tlds "^1.234.0"
 
-"@atproto/api@^0.13.2":
-  version "0.13.2"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863"
-  integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw==
+"@atproto/api@^0.13.0":
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8"
+  integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA==
   dependencies:
     "@atproto/common-web" "^0.3.0"
     "@atproto/lexicon" "^0.4.1"