diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/composer/Composer.tsx | 13 | ||||
-rw-r--r-- | src/view/com/composer/Prompt.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/labels/LabelsBtn.tsx | 5 | ||||
-rw-r--r-- | src/view/com/composer/text-input/web/EmojiPicker.web.tsx | 3 | ||||
-rw-r--r-- | src/view/com/composer/threadgate/ThreadgateBtn.tsx | 68 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 3 | ||||
-rw-r--r-- | src/view/com/modals/Threadgate.tsx | 204 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThread.tsx | 2 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 290 | ||||
-rw-r--r-- | src/view/com/threadgate/WhoCanReply.tsx | 183 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/PostCtrls.tsx | 11 |
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} ${ |