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
zio/stable
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

@ -21,6 +21,7 @@ interface TrackPropertiesMap {
'Composer:PastedPhotos': {} 'Composer:PastedPhotos': {}
'Composer:CameraOpened': {} 'Composer:CameraOpened': {}
'Composer:GalleryOpened': {} 'Composer:GalleryOpened': {}
'Composer:ThreadgateOpened': {}
'HomeScreen:PressCompose': {} 'HomeScreen:PressCompose': {}
'ProfileScreen:PressCompose': {} 'ProfileScreen:PressCompose': {}
// EDIT PROFILE events // EDIT PROFILE events

View File

@ -3,6 +3,7 @@ import {
AppBskyEmbedExternal, AppBskyEmbedExternal,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
AppBskyFeedThreadgate,
AppBskyRichtextFacet, AppBskyRichtextFacet,
BskyAgent, BskyAgent,
ComAtprotoLabelDefs, ComAtprotoLabelDefs,
@ -16,6 +17,7 @@ import {isWeb} from 'platform/detection'
import {ImageModel} from 'state/models/media/image' import {ImageModel} from 'state/models/media/image'
import {shortenLinks} from 'lib/strings/rich-text-manip' import {shortenLinks} from 'lib/strings/rich-text-manip'
import {logger} from '#/logger' import {logger} from '#/logger'
import {ThreadgateSetting} from '#/state/queries/threadgate'
export interface ExternalEmbedDraft { export interface ExternalEmbedDraft {
uri: string uri: string
@ -54,6 +56,7 @@ interface PostOpts {
extLink?: ExternalEmbedDraft extLink?: ExternalEmbedDraft
images?: ImageModel[] images?: ImageModel[]
labels?: string[] labels?: string[]
threadgate?: ThreadgateSetting[]
onStateChange?: (state: string) => void onStateChange?: (state: string) => void
langs?: string[] langs?: string[]
} }
@ -227,9 +230,10 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
langs = opts.langs.slice(0, 3) langs = opts.langs.slice(0, 3)
} }
let res
try { try {
opts.onStateChange?.('Posting...') opts.onStateChange?.('Posting...')
return await agent.post({ res = await agent.post({
text: rt.text, text: rt.text,
facets: rt.facets, facets: rt.facets,
reply, reply,
@ -247,6 +251,52 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
throw e 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 // helpers

View File

@ -51,6 +51,10 @@ msgstr ""
msgid "{message}" msgid "{message}"
msgstr "" msgstr ""
#: src/view/com/threadgate/WhoCanReply.tsx:130
msgid "<0/> members"
msgstr ""
#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30
msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
msgstr "" msgstr ""
@ -804,6 +808,10 @@ msgstr ""
msgid "Error:" msgid "Error:"
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:76
msgid "Everybody"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:156 #: src/view/com/lightbox/Lightbox.web.tsx:156
msgid "Expand alt text" msgid "Expand alt text"
msgstr "" 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." msgid "Follow some users to get started. We can recommend you more users based on who you find interesting."
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:98
msgid "Followed users"
msgstr ""
#: src/view/screens/PreferencesHomeFeed.tsx:145 #: src/view/screens/PreferencesHomeFeed.tsx:145
msgid "Followed users only" msgid "Followed users only"
msgstr "" msgstr ""
@ -1360,6 +1372,10 @@ msgstr ""
msgid "No results found for {query}" msgid "No results found for {query}"
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:82
msgid "Nobody"
msgstr ""
#: src/view/com/modals/SelfLabel.tsx:136 #: src/view/com/modals/SelfLabel.tsx:136
#~ msgid "Not Applicable" #~ msgid "Not Applicable"
#~ msgstr "" #~ msgstr ""
@ -1649,6 +1665,10 @@ msgstr ""
msgid "Removed from list" msgid "Removed from list"
msgstr "" msgstr ""
#: src/view/com/threadgate/WhoCanReply.tsx:74
msgid "Replies to this thread are disabled"
msgstr ""
#: src/view/screens/PreferencesHomeFeed.tsx:135 #: src/view/screens/PreferencesHomeFeed.tsx:135
msgid "Reply Filters" msgid "Reply Filters"
msgstr "" msgstr ""
@ -2241,6 +2261,14 @@ msgstr ""
msgid "Users" msgid "Users"
msgstr "" 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 #: src/view/screens/Settings.tsx:750
msgid "Verify email" msgid "Verify email"
msgstr "" msgstr ""
@ -2306,6 +2334,15 @@ msgstr ""
msgid "Which languages would you like to see in your algorithmic feeds?" msgid "Which languages would you like to see in your algorithmic feeds?"
msgstr "" 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 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
msgid "Wide" msgid "Wide"
msgstr "" msgstr ""

View File

@ -51,6 +51,10 @@ msgstr ""
msgid "{message}" msgid "{message}"
msgstr "" msgstr ""
#: src/view/com/threadgate/WhoCanReply.tsx:130
msgid "<0/> members"
msgstr ""
#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30
msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
msgstr "" msgstr ""
@ -812,6 +816,10 @@ msgstr ""
msgid "Error:" msgid "Error:"
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:76
msgid "Everybody"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:156 #: src/view/com/lightbox/Lightbox.web.tsx:156
msgid "Expand alt text" msgid "Expand alt text"
msgstr "" 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." msgid "Follow some users to get started. We can recommend you more users based on who you find interesting."
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:98
msgid "Followed users"
msgstr ""
#: src/view/screens/PreferencesHomeFeed.tsx:145 #: src/view/screens/PreferencesHomeFeed.tsx:145
msgid "Followed users only" msgid "Followed users only"
msgstr "" msgstr ""
@ -1382,6 +1394,10 @@ msgstr ""
msgid "No results found for {query}" msgid "No results found for {query}"
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:82
msgid "Nobody"
msgstr ""
#: src/view/com/modals/SelfLabel.tsx:136 #: src/view/com/modals/SelfLabel.tsx:136
#~ msgid "Not Applicable" #~ msgid "Not Applicable"
#~ msgstr "" #~ msgstr ""
@ -2275,6 +2291,14 @@ msgstr ""
msgid "Users" msgid "Users"
msgstr "" 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 #: src/view/screens/Settings.tsx:750
msgid "Verify email" msgid "Verify email"
msgstr "" msgstr ""
@ -2340,6 +2364,15 @@ msgstr ""
msgid "Which languages would you like to see in your algorithmic feeds?" msgid "Which languages would you like to see in your algorithmic feeds?"
msgstr "" 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 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
msgid "Wide" msgid "Wide"
msgstr "" msgstr ""

View File

@ -51,6 +51,10 @@ msgstr ""
msgid "{message}" msgid "{message}"
msgstr "" msgstr ""
#: src/view/com/threadgate/WhoCanReply.tsx:130
msgid "<0/> members"
msgstr ""
#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30
msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
msgstr "" msgstr ""
@ -804,6 +808,10 @@ msgstr ""
msgid "Error:" msgid "Error:"
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:76
msgid "Everybody"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:156 #: src/view/com/lightbox/Lightbox.web.tsx:156
msgid "Expand alt text" msgid "Expand alt text"
msgstr "" 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." msgid "Follow some users to get started. We can recommend you more users based on who you find interesting."
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:98
msgid "Followed users"
msgstr ""
#: src/view/screens/PreferencesHomeFeed.tsx:145 #: src/view/screens/PreferencesHomeFeed.tsx:145
msgid "Followed users only" msgid "Followed users only"
msgstr "" msgstr ""
@ -1360,6 +1372,10 @@ msgstr ""
msgid "No results found for {query}" msgid "No results found for {query}"
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:82
msgid "Nobody"
msgstr ""
#: src/view/com/modals/SelfLabel.tsx:136 #: src/view/com/modals/SelfLabel.tsx:136
#~ msgid "Not Applicable" #~ msgid "Not Applicable"
#~ msgstr "" #~ msgstr ""
@ -1649,6 +1665,10 @@ msgstr ""
msgid "Removed from list" msgid "Removed from list"
msgstr "" msgstr ""
#: src/view/com/threadgate/WhoCanReply.tsx:74
msgid "Replies to this thread are disabled"
msgstr ""
#: src/view/screens/PreferencesHomeFeed.tsx:135 #: src/view/screens/PreferencesHomeFeed.tsx:135
msgid "Reply Filters" msgid "Reply Filters"
msgstr "" msgstr ""
@ -2241,6 +2261,14 @@ msgstr ""
msgid "Users" msgid "Users"
msgstr "" 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 #: src/view/screens/Settings.tsx:750
msgid "Verify email" msgid "Verify email"
msgstr "" msgstr ""
@ -2306,6 +2334,15 @@ msgstr ""
msgid "Which languages would you like to see in your algorithmic feeds?" msgid "Which languages would you like to see in your algorithmic feeds?"
msgstr "" 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 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
msgid "Wide" msgid "Wide"
msgstr "" msgstr ""

View File

@ -51,6 +51,10 @@ msgstr ""
msgid "{message}" msgid "{message}"
msgstr "" msgstr ""
#: src/view/com/threadgate/WhoCanReply.tsx:130
msgid "<0/> members"
msgstr ""
#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30
msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
msgstr "" msgstr ""
@ -804,6 +808,10 @@ msgstr ""
msgid "Error:" msgid "Error:"
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:76
msgid "Everybody"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:156 #: src/view/com/lightbox/Lightbox.web.tsx:156
msgid "Expand alt text" msgid "Expand alt text"
msgstr "" 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." msgid "Follow some users to get started. We can recommend you more users based on who you find interesting."
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:98
msgid "Followed users"
msgstr ""
#: src/view/screens/PreferencesHomeFeed.tsx:145 #: src/view/screens/PreferencesHomeFeed.tsx:145
msgid "Followed users only" msgid "Followed users only"
msgstr "" msgstr ""
@ -1360,6 +1372,10 @@ msgstr ""
msgid "No results found for {query}" msgid "No results found for {query}"
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:82
msgid "Nobody"
msgstr ""
#: src/view/com/modals/SelfLabel.tsx:136 #: src/view/com/modals/SelfLabel.tsx:136
#~ msgid "Not Applicable" #~ msgid "Not Applicable"
#~ msgstr "" #~ msgstr ""
@ -1649,6 +1665,10 @@ msgstr ""
msgid "Removed from list" msgid "Removed from list"
msgstr "" msgstr ""
#: src/view/com/threadgate/WhoCanReply.tsx:74
msgid "Replies to this thread are disabled"
msgstr ""
#: src/view/screens/PreferencesHomeFeed.tsx:135 #: src/view/screens/PreferencesHomeFeed.tsx:135
msgid "Reply Filters" msgid "Reply Filters"
msgstr "" msgstr ""
@ -2241,6 +2261,14 @@ msgstr ""
msgid "Users" msgid "Users"
msgstr "" 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 #: src/view/screens/Settings.tsx:750
msgid "Verify email" msgid "Verify email"
msgstr "" msgstr ""
@ -2306,6 +2334,15 @@ msgstr ""
msgid "Which languages would you like to see in your algorithmic feeds?" msgid "Which languages would you like to see in your algorithmic feeds?"
msgstr "" 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 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
msgid "Wide" msgid "Wide"
msgstr "" msgstr ""

View File

@ -51,6 +51,10 @@ msgstr ""
msgid "{message}" msgid "{message}"
msgstr "" msgstr ""
#: src/view/com/threadgate/WhoCanReply.tsx:130
msgid "<0/> members"
msgstr ""
#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30
msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>"
msgstr "<0>अपना</0><1>पसंदीदा</1><2>फ़ीड चुनें</2>" msgstr "<0>अपना</0><1>पसंदीदा</1><2>फ़ीड चुनें</2>"
@ -808,6 +812,10 @@ msgstr "अपने यूज़रनेम और पासवर्ड द
msgid "Error:" msgid "Error:"
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:76
msgid "Everybody"
msgstr ""
#: src/view/com/lightbox/Lightbox.web.tsx:156 #: src/view/com/lightbox/Lightbox.web.tsx:156
msgid "Expand alt text" msgid "Expand alt text"
msgstr "ऑल्ट टेक्स्ट" 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." msgid "Follow some users to get started. We can recommend you more users based on who you find interesting."
msgstr "आरंभ करने के लिए कुछ उपयोगकर्ताओं का अनुसरण करें. आपको कौन दिलचस्प लगता है, इसके आधार पर हम आपको और अधिक उपयोगकर्ताओं की अनुशंसा कर सकते हैं।" msgstr "आरंभ करने के लिए कुछ उपयोगकर्ताओं का अनुसरण करें. आपको कौन दिलचस्प लगता है, इसके आधार पर हम आपको और अधिक उपयोगकर्ताओं की अनुशंसा कर सकते हैं।"
#: src/view/com/modals/Threadgate.tsx:98
msgid "Followed users"
msgstr ""
#: src/view/screens/PreferencesHomeFeed.tsx:145 #: src/view/screens/PreferencesHomeFeed.tsx:145
msgid "Followed users only" msgid "Followed users only"
msgstr "केवल वे यूजर को फ़ॉलो किया गया" msgstr "केवल वे यूजर को फ़ॉलो किया गया"
@ -1374,6 +1386,10 @@ msgstr "\"{query}\" के लिए कोई परिणाम नहीं
msgid "No results found for {query}" msgid "No results found for {query}"
msgstr "" msgstr ""
#: src/view/com/modals/Threadgate.tsx:82
msgid "Nobody"
msgstr ""
#: src/view/com/modals/SelfLabel.tsx:136 #: src/view/com/modals/SelfLabel.tsx:136
#~ msgid "Not Applicable" #~ msgid "Not Applicable"
#~ msgstr "लागू नहीं" #~ msgstr "लागू नहीं"
@ -2267,6 +2283,14 @@ msgstr "यूजर नाम या ईमेल पता"
msgid "Users" msgid "Users"
msgstr "यूजर लोग" 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 #: src/view/screens/Settings.tsx:750
msgid "Verify email" msgid "Verify email"
msgstr "ईमेल सत्यापित करें" msgstr "ईमेल सत्यापित करें"
@ -2332,6 +2356,15 @@ msgstr "इस पोस्ट में किस भाषा का उपय
msgid "Which languages would you like to see in your algorithmic feeds?" msgid "Which languages would you like to see in your algorithmic feeds?"
msgstr "कौन से भाषाएं आपको अपने एल्गोरिदमिक फ़ीड में देखना पसंद करती हैं?" 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 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
msgid "Wide" msgid "Wide"
msgstr "चौड़ा" msgstr "चौड़ा"

View File

@ -6,6 +6,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '#/state/models/media/image' import {ImageModel} from '#/state/models/media/image'
import {GalleryModel} from '#/state/models/media/gallery' import {GalleryModel} from '#/state/models/media/gallery'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {ThreadgateSetting} from '../queries/threadgate'
export interface ConfirmModal { export interface ConfirmModal {
name: 'confirm' name: 'confirm'
@ -121,6 +122,12 @@ export interface SelfLabelModal {
onChange: (labels: string[]) => void onChange: (labels: string[]) => void
} }
export interface ThreadgateModal {
name: 'threadgate'
settings: ThreadgateSetting[]
onChange: (settings: ThreadgateSetting[]) => void
}
export interface ChangeHandleModal { export interface ChangeHandleModal {
name: 'change-handle' name: 'change-handle'
onChanged: () => void onChanged: () => void
@ -207,6 +214,7 @@ export type Modal =
| ServerInputModal | ServerInputModal
| RepostModal | RepostModal
| SelfLabelModal | SelfLabelModal
| ThreadgateModal
// Bluesky access // Bluesky access
| WaitlistModal | WaitlistModal

View File

@ -0,0 +1,5 @@
export type ThreadgateSetting =
| {type: 'nobody'}
| {type: 'mention'}
| {type: 'following'}
| {type: 'list'; list: string}

View File

@ -35,6 +35,7 @@ import {shortenLinks} from 'lib/strings/rich-text-manip'
import {toShortUrl} from 'lib/strings/url-helpers' import {toShortUrl} from 'lib/strings/url-helpers'
import {SelectPhotoBtn} from './photos/SelectPhotoBtn' import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
import {OpenCameraBtn} from './photos/OpenCameraBtn' import {OpenCameraBtn} from './photos/OpenCameraBtn'
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useExternalLinkFetch} from './useExternalLinkFetch' import {useExternalLinkFetch} from './useExternalLinkFetch'
@ -61,6 +62,7 @@ import {useProfileQuery} from '#/state/queries/profile'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {until} from '#/lib/async/until' import {until} from '#/lib/async/until'
import {emitPostCreated} from '#/state/events' import {emitPostCreated} from '#/state/events'
import {ThreadgateSetting} from '#/state/queries/threadgate'
type Props = ComposerOpts type Props = ComposerOpts
export const ComposePost = observer(function ComposePost({ export const ComposePost = observer(function ComposePost({
@ -105,6 +107,7 @@ export const ComposePost = observer(function ComposePost({
) )
const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [labels, setLabels] = useState<string[]>([]) const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
const gallery = useMemo(() => new GalleryModel(), []) const gallery = useMemo(() => new GalleryModel(), [])
const onClose = useCallback(() => { const onClose = useCallback(() => {
@ -220,6 +223,7 @@ export const ComposePost = observer(function ComposePost({
quote, quote,
extLink, extLink,
labels, labels,
threadgate,
onStateChange: setProcessingState, onStateChange: setProcessingState,
langs: toPostLanguages(langPrefs.postLanguage), langs: toPostLanguages(langPrefs.postLanguage),
}) })
@ -296,6 +300,12 @@ export const ComposePost = observer(function ComposePost({
onChange={setLabels} onChange={setLabels}
hasMedia={hasMedia} hasMedia={hasMedia}
/> />
{replyTo ? null : (
<ThreadgateBtn
threadgate={threadgate}
onChange={setThreadgate}
/>
)}
{canPost ? ( {canPost ? (
<TouchableOpacity <TouchableOpacity
testID="composerPublishBtn" testID="composerPublishBtn"
@ -458,9 +468,11 @@ const styles = StyleSheet.create({
topbar: { topbar: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingTop: 6,
paddingBottom: 4, paddingBottom: 4,
paddingHorizontal: 20, paddingHorizontal: 20,
height: 55, height: 55,
gap: 4,
}, },
topbarDesktop: { topbarDesktop: {
paddingTop: 10, paddingTop: 10,
@ -470,6 +482,7 @@ const styles = StyleSheet.create({
borderRadius: 20, borderRadius: 20,
paddingHorizontal: 20, paddingHorizontal: 20,
paddingVertical: 6, paddingVertical: 6,
marginLeft: 12,
}, },
errorLine: { errorLine: {
flexDirection: 'row', flexDirection: 'row',

View File

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

View File

@ -38,7 +38,7 @@ export function LabelsBtn({
} }
openModal({name: 'self-label', labels, hasMedia, onChange}) openModal({name: 'self-label', labels, hasMedia, onChange})
}}> }}>
<ShieldExclamation style={pal.link} size={26} /> <ShieldExclamation style={pal.link} size={24} />
{labels.length > 0 ? ( {labels.length > 0 ? (
<FontAwesomeIcon <FontAwesomeIcon
icon="check" icon="check"
@ -54,8 +54,7 @@ const styles = StyleSheet.create({
button: { button: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 14, paddingHorizontal: 6,
marginRight: 4,
}, },
dimmed: { dimmed: {
opacity: 0.4, opacity: 0.4,

View File

@ -98,7 +98,8 @@ const styles = StyleSheet.create({
backgroundColor: 'transparent', backgroundColor: 'transparent',
border: 'none', border: 'none',
paddingTop: 4, paddingTop: 4,
paddingHorizontal: 10, paddingLeft: 12,
paddingRight: 12,
cursor: 'pointer', cursor: 'pointer',
}, },
picker: { 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 ServerInputModal from './ServerInput'
import * as RepostModal from './Repost' import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel' import * as SelfLabelModal from './SelfLabel'
import * as ThreadgateModal from './Threadgate'
import * as CreateOrEditListModal from './CreateOrEditList' import * as CreateOrEditListModal from './CreateOrEditList'
import * as UserAddRemoveListsModal from './UserAddRemoveLists' import * as UserAddRemoveListsModal from './UserAddRemoveLists'
import * as ListAddUserModal from './ListAddRemoveUsers' import * as ListAddUserModal from './ListAddRemoveUsers'
@ -127,6 +128,9 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'self-label') { } else if (activeModal?.name === 'self-label') {
snapPoints = SelfLabelModal.snapPoints snapPoints = SelfLabelModal.snapPoints
element = <SelfLabelModal.Component {...activeModal} /> element = <SelfLabelModal.Component {...activeModal} />
} else if (activeModal?.name === 'threadgate') {
snapPoints = ThreadgateModal.snapPoints
element = <ThreadgateModal.Component {...activeModal} />
} else if (activeModal?.name === 'alt-text-image') { } else if (activeModal?.name === 'alt-text-image') {
snapPoints = AltImageModal.snapPoints snapPoints = AltImageModal.snapPoints
element = <AltImageModal.Component {...activeModal} /> element = <AltImageModal.Component {...activeModal} />

View File

@ -18,6 +18,7 @@ import * as ListAddUserModal from './ListAddRemoveUsers'
import * as DeleteAccountModal from './DeleteAccount' import * as DeleteAccountModal from './DeleteAccount'
import * as RepostModal from './Repost' import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel' import * as SelfLabelModal from './SelfLabel'
import * as ThreadgateModal from './Threadgate'
import * as CropImageModal from './crop-image/CropImage.web' import * as CropImageModal from './crop-image/CropImage.web'
import * as AltTextImageModal from './AltImage' import * as AltTextImageModal from './AltImage'
import * as EditImageModal from './EditImage' import * as EditImageModal from './EditImage'
@ -98,6 +99,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <RepostModal.Component {...modal} /> element = <RepostModal.Component {...modal} />
} else if (modal.name === 'self-label') { } else if (modal.name === 'self-label') {
element = <SelfLabelModal.Component {...modal} /> element = <SelfLabelModal.Component {...modal} />
} else if (modal.name === 'threadgate') {
element = <ThreadgateModal.Component {...modal} />
} else if (modal.name === 'change-handle') { } else if (modal.name === 'change-handle') {
element = <ChangeHandleModal.Component {...modal} /> element = <ChangeHandleModal.Component {...modal} />
} else if (modal.name === 'waitlist') { } 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 PARENT_SPINNER
} }
yield node yield node
if (node.ctx.isHighlightedPost) { if (node.ctx.isHighlightedPost && !node.post.viewer?.replyDisabled) {
yield REPLY_PROMPT yield REPLY_PROMPT
} }
if (node.replies?.length) { 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 {ThreadPost} from '#/state/queries/post-thread'
import {LabelInfo} from '../util/moderation/LabelInfo' import {LabelInfo} from '../util/moderation/LabelInfo'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {WhoCanReply} from '../threadgate/WhoCanReply'
export function PostThreadItem({ export function PostThreadItem({
post, post,
@ -441,6 +442,7 @@ let PostThreadItemLoaded = ({
</View> </View>
</View> </View>
</Link> </Link>
<WhoCanReply post={post} />
</> </>
) )
} else { } else {
@ -450,6 +452,7 @@ let PostThreadItemLoaded = ({
const isThreadedChildAdjacentBot = const isThreadedChildAdjacentBot =
isThreadedChild && nextPost?.ctx.depth === depth isThreadedChild && nextPost?.ctx.depth === depth
return ( return (
<>
<PostOuterWrapper <PostOuterWrapper
post={post} post={post}
depth={depth} depth={depth}
@ -463,7 +466,9 @@ let PostThreadItemLoaded = ({
moderation={moderation.content} moderation={moderation.content}
iconSize={isThreadedChild ? 26 : 38} iconSize={isThreadedChild ? 26 : 38}
iconStyles={ iconStyles={
isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2} isThreadedChild
? {marginRight: 4}
: {marginLeft: 2, marginRight: 2}
}> }>
<PostSandboxWarning /> <PostSandboxWarning />
@ -608,6 +613,13 @@ let PostThreadItemLoaded = ({
) : undefined} ) : undefined}
</PostHider> </PostHider>
</PostOuterWrapper> </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]}> <View style={[styles.ctrls, style]}>
<TouchableOpacity <TouchableOpacity
testID="replyBtn" 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={() => { onPress={() => {
if (!post.viewer?.replyDisabled) {
requireAuth(() => onPressReply()) requireAuth(() => onPressReply())
}
}} }}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Reply (${post.replyCount} ${ accessibilityLabel={`Reply (${post.replyCount} ${