about summary refs log tree commit diff
path: root/src/components
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 /src/components
parent56ab5e177fa2b24d0e5d9d969aa37532b96128da (diff)
downloadvoidsky-6616a6467ec53aa71e5f823c2d8c46dc01442703.tar.zst
Detached QPs and hidden replies (#4878)
Co-authored-by: Hailey <me@haileyok.com>
Diffstat (limited to 'src/components')
-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
7 files changed, 707 insertions, 390 deletions
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>
   )
 }