about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/analytics/types.ts1
-rw-r--r--src/lib/api/index.ts52
-rw-r--r--src/locale/locales/cs/messages.po37
-rw-r--r--src/locale/locales/en/messages.po33
-rw-r--r--src/locale/locales/es/messages.po37
-rw-r--r--src/locale/locales/fr/messages.po37
-rw-r--r--src/locale/locales/hi/messages.po33
-rw-r--r--src/state/modals/index.tsx8
-rw-r--r--src/state/queries/threadgate.ts5
-rw-r--r--src/view/com/composer/Composer.tsx13
-rw-r--r--src/view/com/composer/Prompt.tsx2
-rw-r--r--src/view/com/composer/labels/LabelsBtn.tsx5
-rw-r--r--src/view/com/composer/text-input/web/EmojiPicker.web.tsx3
-rw-r--r--src/view/com/composer/threadgate/ThreadgateBtn.tsx68
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/modals/Threadgate.tsx204
-rw-r--r--src/view/com/post-thread/PostThread.tsx2
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx290
-rw-r--r--src/view/com/threadgate/WhoCanReply.tsx183
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx11
21 files changed, 883 insertions, 148 deletions
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index 9883cc36e..3d2ebb312 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -21,6 +21,7 @@ interface TrackPropertiesMap {
   'Composer:PastedPhotos': {}
   'Composer:CameraOpened': {}
   'Composer:GalleryOpened': {}
+  'Composer:ThreadgateOpened': {}
   'HomeScreen:PressCompose': {}
   'ProfileScreen:PressCompose': {}
   // EDIT PROFILE events
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index a78abcacd..d94ee4643 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -3,6 +3,7 @@ import {
   AppBskyEmbedExternal,
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
+  AppBskyFeedThreadgate,
   AppBskyRichtextFacet,
   BskyAgent,
   ComAtprotoLabelDefs,
@@ -16,6 +17,7 @@ import {isWeb} from 'platform/detection'
 import {ImageModel} from 'state/models/media/image'
 import {shortenLinks} from 'lib/strings/rich-text-manip'
 import {logger} from '#/logger'
+import {ThreadgateSetting} from '#/state/queries/threadgate'
 
 export interface ExternalEmbedDraft {
   uri: string
@@ -54,6 +56,7 @@ interface PostOpts {
   extLink?: ExternalEmbedDraft
   images?: ImageModel[]
   labels?: string[]
+  threadgate?: ThreadgateSetting[]
   onStateChange?: (state: string) => void
   langs?: string[]
 }
@@ -227,9 +230,10 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
     langs = opts.langs.slice(0, 3)
   }
 
+  let res
   try {
     opts.onStateChange?.('Posting...')
-    return await agent.post({
+    res = await agent.post({
       text: rt.text,
       facets: rt.facets,
       reply,
@@ -247,6 +251,52 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
       throw e
     }
   }
+
+  try {
+    // TODO: this needs to be batch-created with the post!
+    if (opts.threadgate?.length) {
+      await createThreadgate(agent, res.uri, opts.threadgate)
+    }
+  } 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
+}
+
+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,
+        })
+      }
+    }
+  }
+
+  const postUrip = new AtUri(postUri)
+  await agent.api.app.bsky.feed.threadgate.create(
+    {repo: agent.session!.did, rkey: postUrip.rkey},
+    {post: postUri, createdAt: new Date().toISOString(), allow},
+  )
 }
 
 // helpers
diff --git a/src/locale/locales/cs/messages.po b/src/locale/locales/cs/messages.po
index 48c587eb9..ce4ebf113 100644
--- a/src/locale/locales/cs/messages.po
+++ b/src/locale/locales/cs/messages.po
@@ -51,6 +51,10 @@ msgstr ""
 msgid "{message}"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:130
+msgid "<0/> members"
+msgstr ""
+
 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30
 msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
 msgstr ""
@@ -804,6 +808,10 @@ msgstr ""
 msgid "Error:"
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:76
+msgid "Everybody"
+msgstr ""
+
 #: src/view/com/lightbox/Lightbox.web.tsx:156
 msgid "Expand alt text"
 msgstr ""
@@ -866,6 +874,10 @@ msgstr ""
 msgid "Follow some users to get started. We can recommend you more users based on who you find interesting."
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:98
+msgid "Followed users"
+msgstr ""
+
 #: src/view/screens/PreferencesHomeFeed.tsx:145
 msgid "Followed users only"
 msgstr ""
@@ -1360,6 +1372,10 @@ msgstr ""
 msgid "No results found for {query}"
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:82
+msgid "Nobody"
+msgstr ""
+
 #: src/view/com/modals/SelfLabel.tsx:136
 #~ msgid "Not Applicable"
 #~ msgstr ""
@@ -1649,6 +1665,10 @@ msgstr ""
 msgid "Removed from list"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:74
+msgid "Replies to this thread are disabled"
+msgstr ""
+
 #: src/view/screens/PreferencesHomeFeed.tsx:135
 msgid "Reply Filters"
 msgstr ""
@@ -2241,6 +2261,14 @@ msgstr ""
 msgid "Users"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:115
+msgid "Users followed by <0/>"
+msgstr ""
+
+#: src/view/com/modals/Threadgate.tsx:106
+msgid "Users in \"{0}\""
+msgstr ""
+
 #: src/view/screens/Settings.tsx:750
 msgid "Verify email"
 msgstr ""
@@ -2306,6 +2334,15 @@ msgstr ""
 msgid "Which languages would you like to see in your algorithmic feeds?"
 msgstr ""
 
+#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:43
+#: src/view/com/modals/Threadgate.tsx:66
+msgid "Who can reply"
+msgstr ""
+
+#: src/view/com/threadgate/WhoCanReply.tsx:79
+msgid "Who can reply?"
+msgstr ""
+
 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
 msgid "Wide"
 msgstr ""
diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po
index 7a4a182cc..e3d076dbc 100644
--- a/src/locale/locales/en/messages.po
+++ b/src/locale/locales/en/messages.po
@@ -51,6 +51,10 @@ msgstr ""
 msgid "{message}"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:130
+msgid "<0/> members"
+msgstr ""
+
 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30
 msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
 msgstr ""
@@ -812,6 +816,10 @@ msgstr ""
 msgid "Error:"
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:76
+msgid "Everybody"
+msgstr ""
+
 #: src/view/com/lightbox/Lightbox.web.tsx:156
 msgid "Expand alt text"
 msgstr ""
@@ -875,6 +883,10 @@ msgstr ""
 msgid "Follow some users to get started. We can recommend you more users based on who you find interesting."
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:98
+msgid "Followed users"
+msgstr ""
+
 #: src/view/screens/PreferencesHomeFeed.tsx:145
 msgid "Followed users only"
 msgstr ""
@@ -1382,6 +1394,10 @@ msgstr ""
 msgid "No results found for {query}"
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:82
+msgid "Nobody"
+msgstr ""
+
 #: src/view/com/modals/SelfLabel.tsx:136
 #~ msgid "Not Applicable"
 #~ msgstr ""
@@ -2275,6 +2291,14 @@ msgstr ""
 msgid "Users"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:115
+msgid "Users followed by <0/>"
+msgstr ""
+
+#: src/view/com/modals/Threadgate.tsx:106
+msgid "Users in \"{0}\""
+msgstr ""
+
 #: src/view/screens/Settings.tsx:750
 msgid "Verify email"
 msgstr ""
@@ -2340,6 +2364,15 @@ msgstr ""
 msgid "Which languages would you like to see in your algorithmic feeds?"
 msgstr ""
 
+#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:43
+#: src/view/com/modals/Threadgate.tsx:66
+msgid "Who can reply"
+msgstr ""
+
+#: src/view/com/threadgate/WhoCanReply.tsx:79
+msgid "Who can reply?"
+msgstr ""
+
 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
 msgid "Wide"
 msgstr ""
diff --git a/src/locale/locales/es/messages.po b/src/locale/locales/es/messages.po
index c1fca7e90..04aef65a1 100644
--- a/src/locale/locales/es/messages.po
+++ b/src/locale/locales/es/messages.po
@@ -51,6 +51,10 @@ msgstr ""
 msgid "{message}"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:130
+msgid "<0/> members"
+msgstr ""
+
 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30
 msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
 msgstr ""
@@ -804,6 +808,10 @@ msgstr ""
 msgid "Error:"
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:76
+msgid "Everybody"
+msgstr ""
+
 #: src/view/com/lightbox/Lightbox.web.tsx:156
 msgid "Expand alt text"
 msgstr ""
@@ -866,6 +874,10 @@ msgstr ""
 msgid "Follow some users to get started. We can recommend you more users based on who you find interesting."
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:98
+msgid "Followed users"
+msgstr ""
+
 #: src/view/screens/PreferencesHomeFeed.tsx:145
 msgid "Followed users only"
 msgstr ""
@@ -1360,6 +1372,10 @@ msgstr ""
 msgid "No results found for {query}"
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:82
+msgid "Nobody"
+msgstr ""
+
 #: src/view/com/modals/SelfLabel.tsx:136
 #~ msgid "Not Applicable"
 #~ msgstr ""
@@ -1649,6 +1665,10 @@ msgstr ""
 msgid "Removed from list"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:74
+msgid "Replies to this thread are disabled"
+msgstr ""
+
 #: src/view/screens/PreferencesHomeFeed.tsx:135
 msgid "Reply Filters"
 msgstr ""
@@ -2241,6 +2261,14 @@ msgstr ""
 msgid "Users"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:115
+msgid "Users followed by <0/>"
+msgstr ""
+
+#: src/view/com/modals/Threadgate.tsx:106
+msgid "Users in \"{0}\""
+msgstr ""
+
 #: src/view/screens/Settings.tsx:750
 msgid "Verify email"
 msgstr ""
@@ -2306,6 +2334,15 @@ msgstr ""
 msgid "Which languages would you like to see in your algorithmic feeds?"
 msgstr ""
 
+#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:43
+#: src/view/com/modals/Threadgate.tsx:66
+msgid "Who can reply"
+msgstr ""
+
+#: src/view/com/threadgate/WhoCanReply.tsx:79
+msgid "Who can reply?"
+msgstr ""
+
 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
 msgid "Wide"
 msgstr ""
diff --git a/src/locale/locales/fr/messages.po b/src/locale/locales/fr/messages.po
index 414e397c1..0a195de0e 100644
--- a/src/locale/locales/fr/messages.po
+++ b/src/locale/locales/fr/messages.po
@@ -51,6 +51,10 @@ msgstr ""
 msgid "{message}"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:130
+msgid "<0/> members"
+msgstr ""
+
 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30
 msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
 msgstr ""
@@ -804,6 +808,10 @@ msgstr ""
 msgid "Error:"
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:76
+msgid "Everybody"
+msgstr ""
+
 #: src/view/com/lightbox/Lightbox.web.tsx:156
 msgid "Expand alt text"
 msgstr ""
@@ -866,6 +874,10 @@ msgstr ""
 msgid "Follow some users to get started. We can recommend you more users based on who you find interesting."
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:98
+msgid "Followed users"
+msgstr ""
+
 #: src/view/screens/PreferencesHomeFeed.tsx:145
 msgid "Followed users only"
 msgstr ""
@@ -1360,6 +1372,10 @@ msgstr ""
 msgid "No results found for {query}"
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:82
+msgid "Nobody"
+msgstr ""
+
 #: src/view/com/modals/SelfLabel.tsx:136
 #~ msgid "Not Applicable"
 #~ msgstr ""
@@ -1649,6 +1665,10 @@ msgstr ""
 msgid "Removed from list"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:74
+msgid "Replies to this thread are disabled"
+msgstr ""
+
 #: src/view/screens/PreferencesHomeFeed.tsx:135
 msgid "Reply Filters"
 msgstr ""
@@ -2241,6 +2261,14 @@ msgstr ""
 msgid "Users"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:115
+msgid "Users followed by <0/>"
+msgstr ""
+
+#: src/view/com/modals/Threadgate.tsx:106
+msgid "Users in \"{0}\""
+msgstr ""
+
 #: src/view/screens/Settings.tsx:750
 msgid "Verify email"
 msgstr ""
@@ -2306,6 +2334,15 @@ msgstr ""
 msgid "Which languages would you like to see in your algorithmic feeds?"
 msgstr ""
 
+#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:43
+#: src/view/com/modals/Threadgate.tsx:66
+msgid "Who can reply"
+msgstr ""
+
+#: src/view/com/threadgate/WhoCanReply.tsx:79
+msgid "Who can reply?"
+msgstr ""
+
 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
 msgid "Wide"
 msgstr ""
diff --git a/src/locale/locales/hi/messages.po b/src/locale/locales/hi/messages.po
index 3e907ee3c..7f4ff2dde 100644
--- a/src/locale/locales/hi/messages.po
+++ b/src/locale/locales/hi/messages.po
@@ -51,6 +51,10 @@ msgstr ""
 msgid "{message}"
 msgstr ""
 
+#: src/view/com/threadgate/WhoCanReply.tsx:130
+msgid "<0/> members"
+msgstr ""
+
 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30
 msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
 msgstr "<0>अपना</0><1>पसंदीदा</1><2>फ़ीड चुनें</2>"
@@ -808,6 +812,10 @@ msgstr "अपने यूज़रनेम और पासवर्ड द
 msgid "Error:"
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:76
+msgid "Everybody"
+msgstr ""
+
 #: src/view/com/lightbox/Lightbox.web.tsx:156
 msgid "Expand alt text"
 msgstr "ऑल्ट टेक्स्ट"
@@ -867,6 +875,10 @@ msgstr "फॉलो"
 msgid "Follow some users to get started. We can recommend you more users based on who you find interesting."
 msgstr "आरंभ करने के लिए कुछ उपयोगकर्ताओं का अनुसरण करें. आपको कौन दिलचस्प लगता है, इसके आधार पर हम आपको और अधिक उपयोगकर्ताओं की अनुशंसा कर सकते हैं।"
 
+#: src/view/com/modals/Threadgate.tsx:98
+msgid "Followed users"
+msgstr ""
+
 #: src/view/screens/PreferencesHomeFeed.tsx:145
 msgid "Followed users only"
 msgstr "केवल वे यूजर को फ़ॉलो किया गया"
@@ -1374,6 +1386,10 @@ msgstr "\"{query}\" के लिए कोई परिणाम नहीं 
 msgid "No results found for {query}"
 msgstr ""
 
+#: src/view/com/modals/Threadgate.tsx:82
+msgid "Nobody"
+msgstr ""
+
 #: src/view/com/modals/SelfLabel.tsx:136
 #~ msgid "Not Applicable"
 #~ msgstr "लागू नहीं"
@@ -2267,6 +2283,14 @@ msgstr "यूजर नाम या ईमेल पता"
 msgid "Users"
 msgstr "यूजर लोग"
 
+#: src/view/com/threadgate/WhoCanReply.tsx:115
+msgid "Users followed by <0/>"
+msgstr ""
+
+#: src/view/com/modals/Threadgate.tsx:106
+msgid "Users in \"{0}\""
+msgstr ""
+
 #: src/view/screens/Settings.tsx:750
 msgid "Verify email"
 msgstr "ईमेल सत्यापित करें"
@@ -2332,6 +2356,15 @@ msgstr "इस पोस्ट में किस भाषा का उपय
 msgid "Which languages would you like to see in your algorithmic feeds?"
 msgstr "कौन से भाषाएं आपको अपने एल्गोरिदमिक फ़ीड में देखना पसंद करती हैं?"
 
+#: src/view/com/composer/threadgate/ThreadgateBtn.tsx:43
+#: src/view/com/modals/Threadgate.tsx:66
+msgid "Who can reply"
+msgstr ""
+
+#: src/view/com/threadgate/WhoCanReply.tsx:79
+msgid "Who can reply?"
+msgstr ""
+
 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
 msgid "Wide"
 msgstr "चौड़ा"
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index bff27f84d..81a220d1b 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -6,6 +6,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
 import {ImageModel} from '#/state/models/media/image'
 import {GalleryModel} from '#/state/models/media/gallery'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {ThreadgateSetting} from '../queries/threadgate'
 
 export interface ConfirmModal {
   name: 'confirm'
@@ -121,6 +122,12 @@ export interface SelfLabelModal {
   onChange: (labels: string[]) => void
 }
 
+export interface ThreadgateModal {
+  name: 'threadgate'
+  settings: ThreadgateSetting[]
+  onChange: (settings: ThreadgateSetting[]) => void
+}
+
 export interface ChangeHandleModal {
   name: 'change-handle'
   onChanged: () => void
@@ -207,6 +214,7 @@ export type Modal =
   | ServerInputModal
   | RepostModal
   | SelfLabelModal
+  | ThreadgateModal
 
   // Bluesky access
   | WaitlistModal
diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts
new file mode 100644
index 000000000..489117582
--- /dev/null
+++ b/src/state/queries/threadgate.ts
@@ -0,0 +1,5 @@
+export type ThreadgateSetting =
+  | {type: 'nobody'}
+  | {type: 'mention'}
+  | {type: 'following'}
+  | {type: 'list'; list: string}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index d8af6d0ce..97d443451 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -35,6 +35,7 @@ import {shortenLinks} from 'lib/strings/rich-text-manip'
 import {toShortUrl} from 'lib/strings/url-helpers'
 import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
 import {OpenCameraBtn} from './photos/OpenCameraBtn'
+import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useExternalLinkFetch} from './useExternalLinkFetch'
@@ -61,6 +62,7 @@ import {useProfileQuery} from '#/state/queries/profile'
 import {useComposerControls} from '#/state/shell/composer'
 import {until} from '#/lib/async/until'
 import {emitPostCreated} from '#/state/events'
+import {ThreadgateSetting} from '#/state/queries/threadgate'
 
 type Props = ComposerOpts
 export const ComposePost = observer(function ComposePost({
@@ -105,6 +107,7 @@ export const ComposePost = observer(function ComposePost({
   )
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
   const [labels, setLabels] = useState<string[]>([])
+  const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
   const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
   const gallery = useMemo(() => new GalleryModel(), [])
   const onClose = useCallback(() => {
@@ -220,6 +223,7 @@ export const ComposePost = observer(function ComposePost({
           quote,
           extLink,
           labels,
+          threadgate,
           onStateChange: setProcessingState,
           langs: toPostLanguages(langPrefs.postLanguage),
         })
@@ -296,6 +300,12 @@ export const ComposePost = observer(function ComposePost({
                 onChange={setLabels}
                 hasMedia={hasMedia}
               />
+              {replyTo ? null : (
+                <ThreadgateBtn
+                  threadgate={threadgate}
+                  onChange={setThreadgate}
+                />
+              )}
               {canPost ? (
                 <TouchableOpacity
                   testID="composerPublishBtn"
@@ -458,9 +468,11 @@ const styles = StyleSheet.create({
   topbar: {
     flexDirection: 'row',
     alignItems: 'center',
+    paddingTop: 6,
     paddingBottom: 4,
     paddingHorizontal: 20,
     height: 55,
+    gap: 4,
   },
   topbarDesktop: {
     paddingTop: 10,
@@ -470,6 +482,7 @@ const styles = StyleSheet.create({
     borderRadius: 20,
     paddingHorizontal: 20,
     paddingVertical: 6,
+    marginLeft: 12,
   },
   errorLine: {
     flexDirection: 'row',
diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx
index ae055f9ac..9964359ac 100644
--- a/src/view/com/composer/Prompt.tsx
+++ b/src/view/com/composer/Prompt.tsx
@@ -49,6 +49,6 @@ const styles = StyleSheet.create({
     paddingLeft: 12,
   },
   labelDesktopWeb: {
-    paddingLeft: 20,
+    paddingLeft: 12,
   },
 })
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
index a10684691..b880dd330 100644
--- a/src/view/com/composer/labels/LabelsBtn.tsx
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -38,7 +38,7 @@ export function LabelsBtn({
         }
         openModal({name: 'self-label', labels, hasMedia, onChange})
       }}>
-      <ShieldExclamation style={pal.link} size={26} />
+      <ShieldExclamation style={pal.link} size={24} />
       {labels.length > 0 ? (
         <FontAwesomeIcon
           icon="check"
@@ -54,8 +54,7 @@ const styles = StyleSheet.create({
   button: {
     flexDirection: 'row',
     alignItems: 'center',
-    paddingHorizontal: 14,
-    marginRight: 4,
+    paddingHorizontal: 6,
   },
   dimmed: {
     opacity: 0.4,
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index 4031afdaa..09a2dcf41 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -98,7 +98,8 @@ const styles = StyleSheet.create({
     backgroundColor: 'transparent',
     border: 'none',
     paddingTop: 4,
-    paddingHorizontal: 10,
+    paddingLeft: 12,
+    paddingRight: 12,
     cursor: 'pointer',
   },
   picker: {
diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
new file mode 100644
index 000000000..efc4525ae
--- /dev/null
+++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
@@ -0,0 +1,68 @@
+import React from 'react'
+import {TouchableOpacity, StyleSheet} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {HITSLOP_10} from 'lib/constants'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {useModalControls} from '#/state/modals'
+import {ThreadgateSetting} from '#/state/queries/threadgate'
+
+export function ThreadgateBtn({
+  threadgate,
+  onChange,
+}: {
+  threadgate: ThreadgateSetting[]
+  onChange: (v: ThreadgateSetting[]) => void
+}) {
+  const pal = usePalette('default')
+  const {track} = useAnalytics()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
+
+  const onPress = () => {
+    track('Composer:ThreadgateOpened')
+    openModal({
+      name: 'threadgate',
+      settings: threadgate,
+      onChange,
+    })
+  }
+
+  return (
+    <TouchableOpacity
+      testID="openReplyGateButton"
+      onPress={onPress}
+      style={styles.button}
+      hitSlop={HITSLOP_10}
+      accessibilityRole="button"
+      accessibilityLabel={_(msg`Who can reply`)}
+      accessibilityHint="">
+      <FontAwesomeIcon
+        icon={['far', 'comments']}
+        style={pal.link as FontAwesomeIconStyle}
+        size={24}
+      />
+      {threadgate.length ? (
+        <FontAwesomeIcon
+          icon="check"
+          size={16}
+          style={pal.link as FontAwesomeIconStyle}
+        />
+      ) : null}
+    </TouchableOpacity>
+  )
+}
+
+const styles = StyleSheet.create({
+  button: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 6,
+    gap: 4,
+  },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 0384e301c..90629d33d 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -16,6 +16,7 @@ import * as ProfilePreviewModal from './ProfilePreview'
 import * as ServerInputModal from './ServerInput'
 import * as RepostModal from './Repost'
 import * as SelfLabelModal from './SelfLabel'
+import * as ThreadgateModal from './Threadgate'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as UserAddRemoveListsModal from './UserAddRemoveLists'
 import * as ListAddUserModal from './ListAddRemoveUsers'
@@ -127,6 +128,9 @@ export function ModalsContainer() {
   } else if (activeModal?.name === 'self-label') {
     snapPoints = SelfLabelModal.snapPoints
     element = <SelfLabelModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'threadgate') {
+    snapPoints = ThreadgateModal.snapPoints
+    element = <ThreadgateModal.Component {...activeModal} />
   } else if (activeModal?.name === 'alt-text-image') {
     snapPoints = AltImageModal.snapPoints
     element = <AltImageModal.Component {...activeModal} />
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index ce1e67fae..12138f54d 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -18,6 +18,7 @@ import * as ListAddUserModal from './ListAddRemoveUsers'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as RepostModal from './Repost'
 import * as SelfLabelModal from './SelfLabel'
+import * as ThreadgateModal from './Threadgate'
 import * as CropImageModal from './crop-image/CropImage.web'
 import * as AltTextImageModal from './AltImage'
 import * as EditImageModal from './EditImage'
@@ -98,6 +99,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <RepostModal.Component {...modal} />
   } else if (modal.name === 'self-label') {
     element = <SelfLabelModal.Component {...modal} />
+  } else if (modal.name === 'threadgate') {
+    element = <ThreadgateModal.Component {...modal} />
   } else if (modal.name === 'change-handle') {
     element = <ChangeHandleModal.Component {...modal} />
   } else if (modal.name === 'waitlist') {
diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx
new file mode 100644
index 000000000..9d78a2e6d
--- /dev/null
+++ b/src/view/com/modals/Threadgate.tsx
@@ -0,0 +1,204 @@
+import React, {useState} from 'react'
+import {
+  Pressable,
+  StyleProp,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {Text} from '../util/text/Text'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isWeb} from 'platform/detection'
+import {ScrollView} from 'view/com/modals/util'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {ThreadgateSetting} from '#/state/queries/threadgate'
+import {useMyListsQuery} from '#/state/queries/my-lists'
+import isEqual from 'lodash.isequal'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+
+export const snapPoints = ['60%']
+
+export function Component({
+  settings,
+  onChange,
+}: {
+  settings: ThreadgateSetting[]
+  onChange: (settings: ThreadgateSetting[]) => void
+}) {
+  const pal = usePalette('default')
+  const {closeModal} = useModalControls()
+  const [selected, setSelected] = useState(settings)
+  const {_} = useLingui()
+  const {data: lists} = useMyListsQuery('curate')
+
+  const onPressEverybody = () => {
+    setSelected([])
+    onChange([])
+  }
+
+  const onPressNobody = () => {
+    setSelected([{type: 'nobody'}])
+    onChange([{type: 'nobody'}])
+  }
+
+  const onPressAudience = (setting: ThreadgateSetting) => {
+    // remove nobody
+    let newSelected = selected.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)
+    }
+    setSelected(newSelected)
+    onChange(newSelected)
+  }
+
+  return (
+    <View testID="threadgateModal" style={[pal.view, styles.container]}>
+      <View style={styles.titleSection}>
+        <Text type="title-lg" style={[pal.text, styles.title]}>
+          <Trans>Who can reply</Trans>
+        </Text>
+      </View>
+
+      <ScrollView>
+        <Text style={[pal.text, styles.description]}>
+          Choose "Everybody" or "Nobody"
+        </Text>
+        <View style={{flexDirection: 'row', gap: 6, paddingHorizontal: 6}}>
+          <Selectable
+            label={_(msg`Everybody`)}
+            isSelected={selected.length === 0}
+            onPress={onPressEverybody}
+            style={{flex: 1}}
+          />
+          <Selectable
+            label={_(msg`Nobody`)}
+            isSelected={!!selected.find(v => v.type === 'nobody')}
+            onPress={onPressNobody}
+            style={{flex: 1}}
+          />
+        </View>
+        <Text style={[pal.text, styles.description]}>
+          Or combine these options:
+        </Text>
+        <View style={{flexDirection: 'column', gap: 4, paddingHorizontal: 6}}>
+          <Selectable
+            label={_(msg`Mentioned users`)}
+            isSelected={!!selected.find(v => v.type === 'mention')}
+            onPress={() => onPressAudience({type: 'mention'})}
+          />
+          <Selectable
+            label={_(msg`Followed users`)}
+            isSelected={!!selected.find(v => v.type === 'following')}
+            onPress={() => onPressAudience({type: 'following'})}
+          />
+          {lists?.length
+            ? lists.map(list => (
+                <Selectable
+                  key={list.uri}
+                  label={_(msg`Users in "${list.name}"`)}
+                  isSelected={
+                    !!selected.find(
+                      v => v.type === 'list' && v.list === list.uri,
+                    )
+                  }
+                  onPress={() =>
+                    onPressAudience({type: 'list', list: list.uri})
+                  }
+                />
+              ))
+            : null}
+        </View>
+      </ScrollView>
+
+      <View style={[styles.btnContainer, pal.borderDark]}>
+        <TouchableOpacity
+          testID="confirmBtn"
+          onPress={() => {
+            closeModal()
+          }}
+          style={styles.btn}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Done`)}
+          accessibilityHint="">
+          <Text style={[s.white, s.bold, s.f18]}>
+            <Trans>Done</Trans>
+          </Text>
+        </TouchableOpacity>
+      </View>
+    </View>
+  )
+}
+
+function Selectable({
+  label,
+  isSelected,
+  onPress,
+  style,
+}: {
+  label: string
+  isSelected: boolean
+  onPress: () => void
+  style?: StyleProp<ViewStyle>
+}) {
+  const pal = usePalette(isSelected ? 'inverted' : 'default')
+  return (
+    <Pressable
+      onPress={onPress}
+      accessibilityLabel={label}
+      accessibilityHint=""
+      style={[styles.selectable, pal.border, pal.view, style]}>
+      <Text type="xl" style={[pal.text]}>
+        {label}
+      </Text>
+      {isSelected ? (
+        <FontAwesomeIcon icon="check" color={pal.colors.text} size={18} />
+      ) : null}
+    </Pressable>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: isWeb ? 0 : 40,
+  },
+  titleSection: {
+    paddingTop: isWeb ? 0 : 4,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: '600',
+  },
+  description: {
+    textAlign: 'center',
+    paddingVertical: 16,
+  },
+  selectable: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    paddingHorizontal: 18,
+    paddingVertical: 16,
+    borderWidth: 1,
+    borderRadius: 6,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.blue3,
+  },
+  btnContainer: {
+    paddingTop: 20,
+    paddingHorizontal: 20,
+  },
+})
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index cf43d2055..633968c87 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -468,7 +468,7 @@ function* flattenThreadSkeleton(
       yield PARENT_SPINNER
     }
     yield node
-    if (node.ctx.isHighlightedPost) {
+    if (node.ctx.isHighlightedPost && !node.post.viewer?.replyDisabled) {
       yield REPLY_PROMPT
     }
     if (node.replies?.length) {
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index a2aa3716e..2636fdfbd 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -44,6 +44,7 @@ import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 import {ThreadPost} from '#/state/queries/post-thread'
 import {LabelInfo} from '../util/moderation/LabelInfo'
 import {useSession} from '#/state/session'
+import {WhoCanReply} from '../threadgate/WhoCanReply'
 
 export function PostThreadItem({
   post,
@@ -441,6 +442,7 @@ let PostThreadItemLoaded = ({
             </View>
           </View>
         </Link>
+        <WhoCanReply post={post} />
       </>
     )
   } else {
@@ -450,164 +452,174 @@ let PostThreadItemLoaded = ({
     const isThreadedChildAdjacentBot =
       isThreadedChild && nextPost?.ctx.depth === depth
     return (
-      <PostOuterWrapper
-        post={post}
-        depth={depth}
-        showParentReplyLine={!!showParentReplyLine}
-        treeView={treeView}
-        hasPrecedingItem={hasPrecedingItem}>
-        <PostHider
-          testID={`postThreadItem-by-${post.author.handle}`}
-          href={postHref}
-          style={[pal.view]}
-          moderation={moderation.content}
-          iconSize={isThreadedChild ? 26 : 38}
-          iconStyles={
-            isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2}
-          }>
-          <PostSandboxWarning />
-
-          <View
-            style={{
-              flexDirection: 'row',
-              gap: 10,
-              paddingLeft: 8,
-              height: isThreadedChildAdjacentTop ? 8 : 16,
-            }}>
-            <View style={{width: 38}}>
-              {!isThreadedChild && showParentReplyLine && (
-                <View
-                  style={[
-                    styles.replyLine,
-                    {
-                      flexGrow: 1,
-                      backgroundColor: pal.colors.border,
-                      marginBottom: 4,
-                    },
-                  ]}
-                />
-              )}
-            </View>
-          </View>
-
-          <View
-            style={[
-              styles.layout,
-              {
-                paddingBottom:
-                  showChildReplyLine && !isThreadedChild
-                    ? 0
-                    : isThreadedChildAdjacentBot
-                    ? 4
-                    : 8,
-              },
-            ]}>
-            {!isThreadedChild && (
-              <View style={styles.layoutAvi}>
-                <PreviewableUserAvatar
-                  size={38}
-                  did={post.author.did}
-                  handle={post.author.handle}
-                  avatar={post.author.avatar}
-                  moderation={moderation.avatar}
-                />
+      <>
+        <PostOuterWrapper
+          post={post}
+          depth={depth}
+          showParentReplyLine={!!showParentReplyLine}
+          treeView={treeView}
+          hasPrecedingItem={hasPrecedingItem}>
+          <PostHider
+            testID={`postThreadItem-by-${post.author.handle}`}
+            href={postHref}
+            style={[pal.view]}
+            moderation={moderation.content}
+            iconSize={isThreadedChild ? 26 : 38}
+            iconStyles={
+              isThreadedChild
+                ? {marginRight: 4}
+                : {marginLeft: 2, marginRight: 2}
+            }>
+            <PostSandboxWarning />
 
-                {showChildReplyLine && (
+            <View
+              style={{
+                flexDirection: 'row',
+                gap: 10,
+                paddingLeft: 8,
+                height: isThreadedChildAdjacentTop ? 8 : 16,
+              }}>
+              <View style={{width: 38}}>
+                {!isThreadedChild && showParentReplyLine && (
                   <View
                     style={[
                       styles.replyLine,
                       {
                         flexGrow: 1,
                         backgroundColor: pal.colors.border,
-                        marginTop: 4,
+                        marginBottom: 4,
                       },
                     ]}
                   />
                 )}
               </View>
-            )}
+            </View>
 
-            <View style={styles.layoutContent}>
-              <PostMeta
-                author={post.author}
-                authorHasWarning={!!post.author.labels?.length}
-                timestamp={post.indexedAt}
-                postHref={postHref}
-                showAvatar={isThreadedChild}
-                avatarSize={28}
-                displayNameType="md-bold"
-                displayNameStyle={isThreadedChild && s.ml2}
-                style={isThreadedChild && s.mb2}
-              />
-              <PostAlerts
-                moderation={moderation.content}
-                style={styles.alert}
-              />
-              {richText?.text ? (
-                <View style={styles.postTextContainer}>
-                  <RichText
-                    type="post-text"
-                    richText={richText}
-                    style={[pal.text, s.flex1]}
-                    lineHeight={1.3}
-                    numberOfLines={limitLines ? MAX_POST_LINES : undefined}
+            <View
+              style={[
+                styles.layout,
+                {
+                  paddingBottom:
+                    showChildReplyLine && !isThreadedChild
+                      ? 0
+                      : isThreadedChildAdjacentBot
+                      ? 4
+                      : 8,
+                },
+              ]}>
+              {!isThreadedChild && (
+                <View style={styles.layoutAvi}>
+                  <PreviewableUserAvatar
+                    size={38}
+                    did={post.author.did}
+                    handle={post.author.handle}
+                    avatar={post.author.avatar}
+                    moderation={moderation.avatar}
                   />
+
+                  {showChildReplyLine && (
+                    <View
+                      style={[
+                        styles.replyLine,
+                        {
+                          flexGrow: 1,
+                          backgroundColor: pal.colors.border,
+                          marginTop: 4,
+                        },
+                      ]}
+                    />
+                  )}
                 </View>
-              ) : undefined}
-              {limitLines ? (
-                <TextLink
-                  text="Show More"
-                  style={pal.link}
-                  onPress={onPressShowMore}
-                  href="#"
+              )}
+
+              <View style={styles.layoutContent}>
+                <PostMeta
+                  author={post.author}
+                  authorHasWarning={!!post.author.labels?.length}
+                  timestamp={post.indexedAt}
+                  postHref={postHref}
+                  showAvatar={isThreadedChild}
+                  avatarSize={28}
+                  displayNameType="md-bold"
+                  displayNameStyle={isThreadedChild && s.ml2}
+                  style={isThreadedChild && s.mb2}
                 />
-              ) : undefined}
-              {post.embed && (
-                <ContentHider
-                  style={styles.contentHider}
-                  moderation={moderation.embed}
-                  moderationDecisions={moderation.decisions}
-                  ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
-                  ignoreQuoteDecisions>
-                  <PostEmbeds
-                    embed={post.embed}
+                <PostAlerts
+                  moderation={moderation.content}
+                  style={styles.alert}
+                />
+                {richText?.text ? (
+                  <View style={styles.postTextContainer}>
+                    <RichText
+                      type="post-text"
+                      richText={richText}
+                      style={[pal.text, s.flex1]}
+                      lineHeight={1.3}
+                      numberOfLines={limitLines ? MAX_POST_LINES : undefined}
+                    />
+                  </View>
+                ) : undefined}
+                {limitLines ? (
+                  <TextLink
+                    text="Show More"
+                    style={pal.link}
+                    onPress={onPressShowMore}
+                    href="#"
+                  />
+                ) : undefined}
+                {post.embed && (
+                  <ContentHider
+                    style={styles.contentHider}
                     moderation={moderation.embed}
                     moderationDecisions={moderation.decisions}
-                  />
-                </ContentHider>
-              )}
-              <PostCtrls
-                post={post}
-                record={record}
-                onPressReply={onPressReply}
-              />
+                    ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
+                    ignoreQuoteDecisions>
+                    <PostEmbeds
+                      embed={post.embed}
+                      moderation={moderation.embed}
+                      moderationDecisions={moderation.decisions}
+                    />
+                  </ContentHider>
+                )}
+                <PostCtrls
+                  post={post}
+                  record={record}
+                  onPressReply={onPressReply}
+                />
+              </View>
             </View>
-          </View>
-          {hasMore ? (
-            <Link
-              style={[
-                styles.loadMore,
-                {
-                  paddingLeft: treeView ? 8 : 70,
-                  paddingTop: 0,
-                  paddingBottom: treeView ? 4 : 12,
-                },
-              ]}
-              href={postHref}
-              title={itemTitle}
-              noFeedback>
-              <Text type="sm-medium" style={pal.textLight}>
-                More
-              </Text>
-              <FontAwesomeIcon
-                icon="angle-right"
-                color={pal.colors.textLight}
-                size={14}
-              />
-            </Link>
-          ) : undefined}
-        </PostHider>
-      </PostOuterWrapper>
+            {hasMore ? (
+              <Link
+                style={[
+                  styles.loadMore,
+                  {
+                    paddingLeft: treeView ? 8 : 70,
+                    paddingTop: 0,
+                    paddingBottom: treeView ? 4 : 12,
+                  },
+                ]}
+                href={postHref}
+                title={itemTitle}
+                noFeedback>
+                <Text type="sm-medium" style={pal.textLight}>
+                  More
+                </Text>
+                <FontAwesomeIcon
+                  icon="angle-right"
+                  color={pal.colors.textLight}
+                  size={14}
+                />
+              </Link>
+            ) : undefined}
+          </PostHider>
+        </PostOuterWrapper>
+        <WhoCanReply
+          post={post}
+          style={{
+            marginTop: 4,
+          }}
+        />
+      </>
     )
   }
 }
diff --git a/src/view/com/threadgate/WhoCanReply.tsx b/src/view/com/threadgate/WhoCanReply.tsx
new file mode 100644
index 000000000..1c34623d8
--- /dev/null
+++ b/src/view/com/threadgate/WhoCanReply.tsx
@@ -0,0 +1,183 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedThreadgate,
+  AppBskyGraphDefs,
+  AtUri,
+} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {Text} from '../util/text/Text'
+import {TextLink} from '../util/Link'
+import {makeProfileLink, makeListLink} from '#/lib/routes/links'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+
+import {colors} from '#/lib/styles'
+
+export function WhoCanReply({
+  post,
+  style,
+}: {
+  post: AppBskyFeedDefs.PostView
+  style?: StyleProp<ViewStyle>
+}) {
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+  const containerStyles = useColorSchemeStyle(
+    {
+      borderColor: pal.colors.unreadNotifBorder,
+      backgroundColor: pal.colors.unreadNotifBg,
+    },
+    {
+      borderColor: pal.colors.unreadNotifBorder,
+      backgroundColor: pal.colors.unreadNotifBg,
+    },
+  )
+  const iconStyles = useColorSchemeStyle(
+    {
+      backgroundColor: colors.blue3,
+    },
+    {
+      backgroundColor: colors.blue3,
+    },
+  )
+  const textStyles = useColorSchemeStyle(
+    {color: colors.gray7},
+    {color: colors.blue1},
+  )
+  const record = React.useMemo(
+    () =>
+      post.threadgate &&
+      AppBskyFeedThreadgate.isRecord(post.threadgate.record) &&
+      AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success
+        ? post.threadgate.record
+        : null,
+    [post],
+  )
+  if (record) {
+    return (
+      <View
+        style={[
+          {
+            flexDirection: 'row',
+            alignItems: 'center',
+            gap: isMobile ? 8 : 10,
+            paddingHorizontal: isMobile ? 16 : 18,
+            paddingVertical: 12,
+            borderWidth: 1,
+            borderLeftWidth: isMobile ? 0 : 1,
+            borderRightWidth: isMobile ? 0 : 1,
+          },
+          containerStyles,
+          style,
+        ]}>
+        <View
+          style={[
+            {
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'center',
+              width: 32,
+              height: 32,
+              borderRadius: 19,
+            },
+            iconStyles,
+          ]}>
+          <FontAwesomeIcon
+            icon={['far', 'comments']}
+            size={16}
+            color={'#fff'}
+          />
+        </View>
+        <View style={{flex: 1}}>
+          <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
+            {!record.allow?.length ? (
+              <Trans>Replies to this thread are disabled</Trans>
+            ) : (
+              <Trans>
+                Only{' '}
+                {record.allow.map((rule, i) => (
+                  <>
+                    <Rule
+                      key={`rule-${i}`}
+                      rule={rule}
+                      post={post}
+                      lists={post.threadgate!.lists}
+                    />
+                    <Separator
+                      key={`sep-${i}`}
+                      i={i}
+                      length={record.allow!.length}
+                    />
+                  </>
+                ))}{' '}
+                can reply.
+              </Trans>
+            )}
+          </Text>
+        </View>
+      </View>
+    )
+  }
+  return null
+}
+
+function Rule({
+  rule,
+  post,
+  lists,
+}: {
+  rule: any
+  post: AppBskyFeedDefs.PostView
+  lists: AppBskyGraphDefs.ListViewBasic[] | undefined
+}) {
+  const pal = usePalette('default')
+  if (AppBskyFeedThreadgate.isMentionRule(rule)) {
+    return <Trans>mentioned users</Trans>
+  }
+  if (AppBskyFeedThreadgate.isFollowingRule(rule)) {
+    return (
+      <Trans>
+        users followed by{' '}
+        <TextLink
+          href={makeProfileLink(post.author)}
+          text={`@${post.author.handle}`}
+          style={pal.link}
+        />
+      </Trans>
+    )
+  }
+  if (AppBskyFeedThreadgate.isListRule(rule)) {
+    const list = lists?.find(l => l.uri === rule.list)
+    if (list) {
+      const listUrip = new AtUri(list.uri)
+      return (
+        <Trans>
+          <TextLink
+            href={makeListLink(listUrip.hostname, listUrip.rkey)}
+            text={list.name}
+            style={pal.link}
+          />{' '}
+          members
+        </Trans>
+      )
+    }
+  }
+}
+
+function Separator({i, length}: {i: number; length: number}) {
+  if (length < 2 || i === length - 1) {
+    return null
+  }
+  if (i === length - 2) {
+    return (
+      <>
+        {length > 2 ? ',' : ''} <Trans>and</Trans>{' '}
+      </>
+    )
+  }
+  return <>, </>
+}
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index e548c45f7..c0c5d470e 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -108,9 +108,16 @@ export function PostCtrls({
     <View style={[styles.ctrls, style]}>
       <TouchableOpacity
         testID="replyBtn"
-        style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
+        style={[
+          styles.ctrl,
+          !big && styles.ctrlPad,
+          {paddingLeft: 0},
+          post.viewer?.replyDisabled ? {opacity: 0.5} : undefined,
+        ]}
         onPress={() => {
-          requireAuth(() => onPressReply())
+          if (!post.viewer?.replyDisabled) {
+            requireAuth(() => onPressReply())
+          }
         }}
         accessibilityRole="button"
         accessibilityLabel={`Reply (${post.replyCount} ${