Add "Who can reply" controls [WIP] (#1954)

* Add threadgating

* UI improvements

* More ui work

* Remove comment

* Tweak colors

* Add missing keys

* Tweak sizing

* Only show composer option on non-reply

* Flex wrap fix

* Move the threadgate control to the top of the composer
This commit is contained in:
Paul Frazee 2023-12-10 12:01:34 -08:00 committed by GitHub
parent f5d014d4c7
commit 28fa5e4919
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 883 additions and 148 deletions

View file

@ -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',

View file

@ -49,6 +49,6 @@ const styles = StyleSheet.create({
paddingLeft: 12,
},
labelDesktopWeb: {
paddingLeft: 20,
paddingLeft: 12,
},
})

View file

@ -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,

View file

@ -98,7 +98,8 @@ const styles = StyleSheet.create({
backgroundColor: 'transparent',
border: 'none',
paddingTop: 4,
paddingHorizontal: 10,
paddingLeft: 12,
paddingRight: 12,
cursor: 'pointer',
},
picker: {

View file

@ -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,
},
})

View file

@ -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} />

View file

@ -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') {

View file

@ -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,
},
})

View file

@ -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) {

View file

@ -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 />
<>
<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}
/>
{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,
}}
/>
</>
)
}
}

View file

@ -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 <>, </>
}

View file

@ -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} ${