diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-12-10 12:01:34 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-12-10 12:01:34 -0800 |
commit | 28fa5e4919ccf24073ccc92d88efb7e4b73b0b2b (patch) | |
tree | df35206692d0d1adce2a32a2ba8188fb4ac5ad26 /src | |
parent | f5d014d4c7b42213e41f2cbc75d318e6462e6995 (diff) | |
download | voidsky-28fa5e4919ccf24073ccc92d88efb7e4b73b0b2b.tar.zst |
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
Diffstat (limited to 'src')
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} ${ |