about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-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
12 files changed, 641 insertions, 147 deletions
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} ${