Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Paul Frazee and committed by
GitHub
28fa5e49 f5d014d4

+883 -148
+1
src/lib/analytics/types.ts
··· 21 21 'Composer:PastedPhotos': {} 22 22 'Composer:CameraOpened': {} 23 23 'Composer:GalleryOpened': {} 24 + 'Composer:ThreadgateOpened': {} 24 25 'HomeScreen:PressCompose': {} 25 26 'ProfileScreen:PressCompose': {} 26 27 // EDIT PROFILE events
+51 -1
src/lib/api/index.ts
··· 3 3 AppBskyEmbedExternal, 4 4 AppBskyEmbedRecord, 5 5 AppBskyEmbedRecordWithMedia, 6 + AppBskyFeedThreadgate, 6 7 AppBskyRichtextFacet, 7 8 BskyAgent, 8 9 ComAtprotoLabelDefs, ··· 16 17 import {ImageModel} from 'state/models/media/image' 17 18 import {shortenLinks} from 'lib/strings/rich-text-manip' 18 19 import {logger} from '#/logger' 20 + import {ThreadgateSetting} from '#/state/queries/threadgate' 19 21 20 22 export interface ExternalEmbedDraft { 21 23 uri: string ··· 54 56 extLink?: ExternalEmbedDraft 55 57 images?: ImageModel[] 56 58 labels?: string[] 59 + threadgate?: ThreadgateSetting[] 57 60 onStateChange?: (state: string) => void 58 61 langs?: string[] 59 62 } ··· 227 230 langs = opts.langs.slice(0, 3) 228 231 } 229 232 233 + let res 230 234 try { 231 235 opts.onStateChange?.('Posting...') 232 - return await agent.post({ 236 + res = await agent.post({ 233 237 text: rt.text, 234 238 facets: rt.facets, 235 239 reply, ··· 247 251 throw e 248 252 } 249 253 } 254 + 255 + try { 256 + // TODO: this needs to be batch-created with the post! 257 + if (opts.threadgate?.length) { 258 + await createThreadgate(agent, res.uri, opts.threadgate) 259 + } 260 + } catch (e: any) { 261 + console.error(`Failed to create threadgate: ${e.toString()}`) 262 + throw new Error( 263 + 'Post reply-controls failed to be set. Your post was created but anyone can reply to it.', 264 + ) 265 + } 266 + 267 + return res 268 + } 269 + 270 + async function createThreadgate( 271 + agent: BskyAgent, 272 + postUri: string, 273 + threadgate: ThreadgateSetting[], 274 + ) { 275 + let allow: ( 276 + | AppBskyFeedThreadgate.MentionRule 277 + | AppBskyFeedThreadgate.FollowingRule 278 + | AppBskyFeedThreadgate.ListRule 279 + )[] = [] 280 + if (!threadgate.find(v => v.type === 'nobody')) { 281 + for (const rule of threadgate) { 282 + if (rule.type === 'mention') { 283 + allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'}) 284 + } else if (rule.type === 'following') { 285 + allow.push({$type: 'app.bsky.feed.threadgate#followingRule'}) 286 + } else if (rule.type === 'list') { 287 + allow.push({ 288 + $type: 'app.bsky.feed.threadgate#listRule', 289 + list: rule.list, 290 + }) 291 + } 292 + } 293 + } 294 + 295 + const postUrip = new AtUri(postUri) 296 + await agent.api.app.bsky.feed.threadgate.create( 297 + {repo: agent.session!.did, rkey: postUrip.rkey}, 298 + {post: postUri, createdAt: new Date().toISOString(), allow}, 299 + ) 250 300 } 251 301 252 302 // helpers
+37
src/locale/locales/cs/messages.po
··· 51 51 msgid "{message}" 52 52 msgstr "" 53 53 54 + #: src/view/com/threadgate/WhoCanReply.tsx:130 55 + msgid "<0/> members" 56 + msgstr "" 57 + 54 58 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 55 59 msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" 56 60 msgstr "" ··· 804 808 msgid "Error:" 805 809 msgstr "" 806 810 811 + #: src/view/com/modals/Threadgate.tsx:76 812 + msgid "Everybody" 813 + msgstr "" 814 + 807 815 #: src/view/com/lightbox/Lightbox.web.tsx:156 808 816 msgid "Expand alt text" 809 817 msgstr "" ··· 866 874 msgid "Follow some users to get started. We can recommend you more users based on who you find interesting." 867 875 msgstr "" 868 876 877 + #: src/view/com/modals/Threadgate.tsx:98 878 + msgid "Followed users" 879 + msgstr "" 880 + 869 881 #: src/view/screens/PreferencesHomeFeed.tsx:145 870 882 msgid "Followed users only" 871 883 msgstr "" ··· 1360 1372 msgid "No results found for {query}" 1361 1373 msgstr "" 1362 1374 1375 + #: src/view/com/modals/Threadgate.tsx:82 1376 + msgid "Nobody" 1377 + msgstr "" 1378 + 1363 1379 #: src/view/com/modals/SelfLabel.tsx:136 1364 1380 #~ msgid "Not Applicable" 1365 1381 #~ msgstr "" ··· 1649 1665 msgid "Removed from list" 1650 1666 msgstr "" 1651 1667 1668 + #: src/view/com/threadgate/WhoCanReply.tsx:74 1669 + msgid "Replies to this thread are disabled" 1670 + msgstr "" 1671 + 1652 1672 #: src/view/screens/PreferencesHomeFeed.tsx:135 1653 1673 msgid "Reply Filters" 1654 1674 msgstr "" ··· 2241 2261 msgid "Users" 2242 2262 msgstr "" 2243 2263 2264 + #: src/view/com/threadgate/WhoCanReply.tsx:115 2265 + msgid "Users followed by <0/>" 2266 + msgstr "" 2267 + 2268 + #: src/view/com/modals/Threadgate.tsx:106 2269 + msgid "Users in \"{0}\"" 2270 + msgstr "" 2271 + 2244 2272 #: src/view/screens/Settings.tsx:750 2245 2273 msgid "Verify email" 2246 2274 msgstr "" ··· 2304 2332 2305 2333 #: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 2306 2334 msgid "Which languages would you like to see in your algorithmic feeds?" 2335 + msgstr "" 2336 + 2337 + #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:43 2338 + #: src/view/com/modals/Threadgate.tsx:66 2339 + msgid "Who can reply" 2340 + msgstr "" 2341 + 2342 + #: src/view/com/threadgate/WhoCanReply.tsx:79 2343 + msgid "Who can reply?" 2307 2344 msgstr "" 2308 2345 2309 2346 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
+33
src/locale/locales/en/messages.po
··· 51 51 msgid "{message}" 52 52 msgstr "" 53 53 54 + #: src/view/com/threadgate/WhoCanReply.tsx:130 55 + msgid "<0/> members" 56 + msgstr "" 57 + 54 58 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 55 59 msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" 56 60 msgstr "" ··· 812 816 msgid "Error:" 813 817 msgstr "" 814 818 819 + #: src/view/com/modals/Threadgate.tsx:76 820 + msgid "Everybody" 821 + msgstr "" 822 + 815 823 #: src/view/com/lightbox/Lightbox.web.tsx:156 816 824 msgid "Expand alt text" 817 825 msgstr "" ··· 875 883 msgid "Follow some users to get started. We can recommend you more users based on who you find interesting." 876 884 msgstr "" 877 885 886 + #: src/view/com/modals/Threadgate.tsx:98 887 + msgid "Followed users" 888 + msgstr "" 889 + 878 890 #: src/view/screens/PreferencesHomeFeed.tsx:145 879 891 msgid "Followed users only" 880 892 msgstr "" ··· 1380 1392 #: src/view/screens/Search/Search.tsx:581 1381 1393 #: src/view/shell/desktop/Search.tsx:210 1382 1394 msgid "No results found for {query}" 1395 + msgstr "" 1396 + 1397 + #: src/view/com/modals/Threadgate.tsx:82 1398 + msgid "Nobody" 1383 1399 msgstr "" 1384 1400 1385 1401 #: src/view/com/modals/SelfLabel.tsx:136 ··· 2275 2291 msgid "Users" 2276 2292 msgstr "" 2277 2293 2294 + #: src/view/com/threadgate/WhoCanReply.tsx:115 2295 + msgid "Users followed by <0/>" 2296 + msgstr "" 2297 + 2298 + #: src/view/com/modals/Threadgate.tsx:106 2299 + msgid "Users in \"{0}\"" 2300 + msgstr "" 2301 + 2278 2302 #: src/view/screens/Settings.tsx:750 2279 2303 msgid "Verify email" 2280 2304 msgstr "" ··· 2338 2362 2339 2363 #: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 2340 2364 msgid "Which languages would you like to see in your algorithmic feeds?" 2365 + msgstr "" 2366 + 2367 + #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:43 2368 + #: src/view/com/modals/Threadgate.tsx:66 2369 + msgid "Who can reply" 2370 + msgstr "" 2371 + 2372 + #: src/view/com/threadgate/WhoCanReply.tsx:79 2373 + msgid "Who can reply?" 2341 2374 msgstr "" 2342 2375 2343 2376 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
+37
src/locale/locales/es/messages.po
··· 51 51 msgid "{message}" 52 52 msgstr "" 53 53 54 + #: src/view/com/threadgate/WhoCanReply.tsx:130 55 + msgid "<0/> members" 56 + msgstr "" 57 + 54 58 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 55 59 msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" 56 60 msgstr "" ··· 804 808 msgid "Error:" 805 809 msgstr "" 806 810 811 + #: src/view/com/modals/Threadgate.tsx:76 812 + msgid "Everybody" 813 + msgstr "" 814 + 807 815 #: src/view/com/lightbox/Lightbox.web.tsx:156 808 816 msgid "Expand alt text" 809 817 msgstr "" ··· 866 874 msgid "Follow some users to get started. We can recommend you more users based on who you find interesting." 867 875 msgstr "" 868 876 877 + #: src/view/com/modals/Threadgate.tsx:98 878 + msgid "Followed users" 879 + msgstr "" 880 + 869 881 #: src/view/screens/PreferencesHomeFeed.tsx:145 870 882 msgid "Followed users only" 871 883 msgstr "" ··· 1360 1372 msgid "No results found for {query}" 1361 1373 msgstr "" 1362 1374 1375 + #: src/view/com/modals/Threadgate.tsx:82 1376 + msgid "Nobody" 1377 + msgstr "" 1378 + 1363 1379 #: src/view/com/modals/SelfLabel.tsx:136 1364 1380 #~ msgid "Not Applicable" 1365 1381 #~ msgstr "" ··· 1649 1665 msgid "Removed from list" 1650 1666 msgstr "" 1651 1667 1668 + #: src/view/com/threadgate/WhoCanReply.tsx:74 1669 + msgid "Replies to this thread are disabled" 1670 + msgstr "" 1671 + 1652 1672 #: src/view/screens/PreferencesHomeFeed.tsx:135 1653 1673 msgid "Reply Filters" 1654 1674 msgstr "" ··· 2241 2261 msgid "Users" 2242 2262 msgstr "" 2243 2263 2264 + #: src/view/com/threadgate/WhoCanReply.tsx:115 2265 + msgid "Users followed by <0/>" 2266 + msgstr "" 2267 + 2268 + #: src/view/com/modals/Threadgate.tsx:106 2269 + msgid "Users in \"{0}\"" 2270 + msgstr "" 2271 + 2244 2272 #: src/view/screens/Settings.tsx:750 2245 2273 msgid "Verify email" 2246 2274 msgstr "" ··· 2304 2332 2305 2333 #: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 2306 2334 msgid "Which languages would you like to see in your algorithmic feeds?" 2335 + msgstr "" 2336 + 2337 + #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:43 2338 + #: src/view/com/modals/Threadgate.tsx:66 2339 + msgid "Who can reply" 2340 + msgstr "" 2341 + 2342 + #: src/view/com/threadgate/WhoCanReply.tsx:79 2343 + msgid "Who can reply?" 2307 2344 msgstr "" 2308 2345 2309 2346 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
+37
src/locale/locales/fr/messages.po
··· 51 51 msgid "{message}" 52 52 msgstr "" 53 53 54 + #: src/view/com/threadgate/WhoCanReply.tsx:130 55 + msgid "<0/> members" 56 + msgstr "" 57 + 54 58 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 55 59 msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" 56 60 msgstr "" ··· 804 808 msgid "Error:" 805 809 msgstr "" 806 810 811 + #: src/view/com/modals/Threadgate.tsx:76 812 + msgid "Everybody" 813 + msgstr "" 814 + 807 815 #: src/view/com/lightbox/Lightbox.web.tsx:156 808 816 msgid "Expand alt text" 809 817 msgstr "" ··· 866 874 msgid "Follow some users to get started. We can recommend you more users based on who you find interesting." 867 875 msgstr "" 868 876 877 + #: src/view/com/modals/Threadgate.tsx:98 878 + msgid "Followed users" 879 + msgstr "" 880 + 869 881 #: src/view/screens/PreferencesHomeFeed.tsx:145 870 882 msgid "Followed users only" 871 883 msgstr "" ··· 1360 1372 msgid "No results found for {query}" 1361 1373 msgstr "" 1362 1374 1375 + #: src/view/com/modals/Threadgate.tsx:82 1376 + msgid "Nobody" 1377 + msgstr "" 1378 + 1363 1379 #: src/view/com/modals/SelfLabel.tsx:136 1364 1380 #~ msgid "Not Applicable" 1365 1381 #~ msgstr "" ··· 1649 1665 msgid "Removed from list" 1650 1666 msgstr "" 1651 1667 1668 + #: src/view/com/threadgate/WhoCanReply.tsx:74 1669 + msgid "Replies to this thread are disabled" 1670 + msgstr "" 1671 + 1652 1672 #: src/view/screens/PreferencesHomeFeed.tsx:135 1653 1673 msgid "Reply Filters" 1654 1674 msgstr "" ··· 2241 2261 msgid "Users" 2242 2262 msgstr "" 2243 2263 2264 + #: src/view/com/threadgate/WhoCanReply.tsx:115 2265 + msgid "Users followed by <0/>" 2266 + msgstr "" 2267 + 2268 + #: src/view/com/modals/Threadgate.tsx:106 2269 + msgid "Users in \"{0}\"" 2270 + msgstr "" 2271 + 2244 2272 #: src/view/screens/Settings.tsx:750 2245 2273 msgid "Verify email" 2246 2274 msgstr "" ··· 2304 2332 2305 2333 #: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 2306 2334 msgid "Which languages would you like to see in your algorithmic feeds?" 2335 + msgstr "" 2336 + 2337 + #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:43 2338 + #: src/view/com/modals/Threadgate.tsx:66 2339 + msgid "Who can reply" 2340 + msgstr "" 2341 + 2342 + #: src/view/com/threadgate/WhoCanReply.tsx:79 2343 + msgid "Who can reply?" 2307 2344 msgstr "" 2308 2345 2309 2346 #: src/view/com/modals/crop-image/CropImage.web.tsx:102
+33
src/locale/locales/hi/messages.po
··· 51 51 msgid "{message}" 52 52 msgstr "" 53 53 54 + #: src/view/com/threadgate/WhoCanReply.tsx:130 55 + msgid "<0/> members" 56 + msgstr "" 57 + 54 58 #: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 55 59 msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" 56 60 msgstr "<0>अपना</0><1>पसंदीदा</1><2>फ़ीड चुनें</2>" ··· 808 812 msgid "Error:" 809 813 msgstr "" 810 814 815 + #: src/view/com/modals/Threadgate.tsx:76 816 + msgid "Everybody" 817 + msgstr "" 818 + 811 819 #: src/view/com/lightbox/Lightbox.web.tsx:156 812 820 msgid "Expand alt text" 813 821 msgstr "ऑल्ट टेक्स्ट" ··· 867 875 msgid "Follow some users to get started. We can recommend you more users based on who you find interesting." 868 876 msgstr "आरंभ करने के लिए कुछ उपयोगकर्ताओं का अनुसरण करें. आपको कौन दिलचस्प लगता है, इसके आधार पर हम आपको और अधिक उपयोगकर्ताओं की अनुशंसा कर सकते हैं।" 869 877 878 + #: src/view/com/modals/Threadgate.tsx:98 879 + msgid "Followed users" 880 + msgstr "" 881 + 870 882 #: src/view/screens/PreferencesHomeFeed.tsx:145 871 883 msgid "Followed users only" 872 884 msgstr "केवल वे यूजर को फ़ॉलो किया गया" ··· 1372 1384 #: src/view/screens/Search/Search.tsx:581 1373 1385 #: src/view/shell/desktop/Search.tsx:210 1374 1386 msgid "No results found for {query}" 1387 + msgstr "" 1388 + 1389 + #: src/view/com/modals/Threadgate.tsx:82 1390 + msgid "Nobody" 1375 1391 msgstr "" 1376 1392 1377 1393 #: src/view/com/modals/SelfLabel.tsx:136 ··· 2267 2283 msgid "Users" 2268 2284 msgstr "यूजर लोग" 2269 2285 2286 + #: src/view/com/threadgate/WhoCanReply.tsx:115 2287 + msgid "Users followed by <0/>" 2288 + msgstr "" 2289 + 2290 + #: src/view/com/modals/Threadgate.tsx:106 2291 + msgid "Users in \"{0}\"" 2292 + msgstr "" 2293 + 2270 2294 #: src/view/screens/Settings.tsx:750 2271 2295 msgid "Verify email" 2272 2296 msgstr "ईमेल सत्यापित करें" ··· 2331 2355 #: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 2332 2356 msgid "Which languages would you like to see in your algorithmic feeds?" 2333 2357 msgstr "कौन से भाषाएं आपको अपने एल्गोरिदमिक फ़ीड में देखना पसंद करती हैं?" 2358 + 2359 + #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:43 2360 + #: src/view/com/modals/Threadgate.tsx:66 2361 + msgid "Who can reply" 2362 + msgstr "" 2363 + 2364 + #: src/view/com/threadgate/WhoCanReply.tsx:79 2365 + msgid "Who can reply?" 2366 + msgstr "" 2334 2367 2335 2368 #: src/view/com/modals/crop-image/CropImage.web.tsx:102 2336 2369 msgid "Wide"
+8
src/state/modals/index.tsx
··· 6 6 import {ImageModel} from '#/state/models/media/image' 7 7 import {GalleryModel} from '#/state/models/media/gallery' 8 8 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 9 + import {ThreadgateSetting} from '../queries/threadgate' 9 10 10 11 export interface ConfirmModal { 11 12 name: 'confirm' ··· 121 122 onChange: (labels: string[]) => void 122 123 } 123 124 125 + export interface ThreadgateModal { 126 + name: 'threadgate' 127 + settings: ThreadgateSetting[] 128 + onChange: (settings: ThreadgateSetting[]) => void 129 + } 130 + 124 131 export interface ChangeHandleModal { 125 132 name: 'change-handle' 126 133 onChanged: () => void ··· 207 214 | ServerInputModal 208 215 | RepostModal 209 216 | SelfLabelModal 217 + | ThreadgateModal 210 218 211 219 // Bluesky access 212 220 | WaitlistModal
+5
src/state/queries/threadgate.ts
··· 1 + export type ThreadgateSetting = 2 + | {type: 'nobody'} 3 + | {type: 'mention'} 4 + | {type: 'following'} 5 + | {type: 'list'; list: string}
+13
src/view/com/composer/Composer.tsx
··· 35 35 import {toShortUrl} from 'lib/strings/url-helpers' 36 36 import {SelectPhotoBtn} from './photos/SelectPhotoBtn' 37 37 import {OpenCameraBtn} from './photos/OpenCameraBtn' 38 + import {ThreadgateBtn} from './threadgate/ThreadgateBtn' 38 39 import {usePalette} from 'lib/hooks/usePalette' 39 40 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 40 41 import {useExternalLinkFetch} from './useExternalLinkFetch' ··· 61 62 import {useComposerControls} from '#/state/shell/composer' 62 63 import {until} from '#/lib/async/until' 63 64 import {emitPostCreated} from '#/state/events' 65 + import {ThreadgateSetting} from '#/state/queries/threadgate' 64 66 65 67 type Props = ComposerOpts 66 68 export const ComposePost = observer(function ComposePost({ ··· 105 107 ) 106 108 const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) 107 109 const [labels, setLabels] = useState<string[]>([]) 110 + const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([]) 108 111 const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) 109 112 const gallery = useMemo(() => new GalleryModel(), []) 110 113 const onClose = useCallback(() => { ··· 220 223 quote, 221 224 extLink, 222 225 labels, 226 + threadgate, 223 227 onStateChange: setProcessingState, 224 228 langs: toPostLanguages(langPrefs.postLanguage), 225 229 }) ··· 296 300 onChange={setLabels} 297 301 hasMedia={hasMedia} 298 302 /> 303 + {replyTo ? null : ( 304 + <ThreadgateBtn 305 + threadgate={threadgate} 306 + onChange={setThreadgate} 307 + /> 308 + )} 299 309 {canPost ? ( 300 310 <TouchableOpacity 301 311 testID="composerPublishBtn" ··· 458 468 topbar: { 459 469 flexDirection: 'row', 460 470 alignItems: 'center', 471 + paddingTop: 6, 461 472 paddingBottom: 4, 462 473 paddingHorizontal: 20, 463 474 height: 55, 475 + gap: 4, 464 476 }, 465 477 topbarDesktop: { 466 478 paddingTop: 10, ··· 470 482 borderRadius: 20, 471 483 paddingHorizontal: 20, 472 484 paddingVertical: 6, 485 + marginLeft: 12, 473 486 }, 474 487 errorLine: { 475 488 flexDirection: 'row',
+1 -1
src/view/com/composer/Prompt.tsx
··· 49 49 paddingLeft: 12, 50 50 }, 51 51 labelDesktopWeb: { 52 - paddingLeft: 20, 52 + paddingLeft: 12, 53 53 }, 54 54 })
+2 -3
src/view/com/composer/labels/LabelsBtn.tsx
··· 38 38 } 39 39 openModal({name: 'self-label', labels, hasMedia, onChange}) 40 40 }}> 41 - <ShieldExclamation style={pal.link} size={26} /> 41 + <ShieldExclamation style={pal.link} size={24} /> 42 42 {labels.length > 0 ? ( 43 43 <FontAwesomeIcon 44 44 icon="check" ··· 54 54 button: { 55 55 flexDirection: 'row', 56 56 alignItems: 'center', 57 - paddingHorizontal: 14, 58 - marginRight: 4, 57 + paddingHorizontal: 6, 59 58 }, 60 59 dimmed: { 61 60 opacity: 0.4,
+2 -1
src/view/com/composer/text-input/web/EmojiPicker.web.tsx
··· 98 98 backgroundColor: 'transparent', 99 99 border: 'none', 100 100 paddingTop: 4, 101 - paddingHorizontal: 10, 101 + paddingLeft: 12, 102 + paddingRight: 12, 102 103 cursor: 'pointer', 103 104 }, 104 105 picker: {
+68
src/view/com/composer/threadgate/ThreadgateBtn.tsx
··· 1 + import React from 'react' 2 + import {TouchableOpacity, StyleSheet} from 'react-native' 3 + import { 4 + FontAwesomeIcon, 5 + FontAwesomeIconStyle, 6 + } from '@fortawesome/react-native-fontawesome' 7 + import {usePalette} from 'lib/hooks/usePalette' 8 + import {useAnalytics} from 'lib/analytics/analytics' 9 + import {HITSLOP_10} from 'lib/constants' 10 + import {useLingui} from '@lingui/react' 11 + import {msg} from '@lingui/macro' 12 + import {useModalControls} from '#/state/modals' 13 + import {ThreadgateSetting} from '#/state/queries/threadgate' 14 + 15 + export function ThreadgateBtn({ 16 + threadgate, 17 + onChange, 18 + }: { 19 + threadgate: ThreadgateSetting[] 20 + onChange: (v: ThreadgateSetting[]) => void 21 + }) { 22 + const pal = usePalette('default') 23 + const {track} = useAnalytics() 24 + const {_} = useLingui() 25 + const {openModal} = useModalControls() 26 + 27 + const onPress = () => { 28 + track('Composer:ThreadgateOpened') 29 + openModal({ 30 + name: 'threadgate', 31 + settings: threadgate, 32 + onChange, 33 + }) 34 + } 35 + 36 + return ( 37 + <TouchableOpacity 38 + testID="openReplyGateButton" 39 + onPress={onPress} 40 + style={styles.button} 41 + hitSlop={HITSLOP_10} 42 + accessibilityRole="button" 43 + accessibilityLabel={_(msg`Who can reply`)} 44 + accessibilityHint=""> 45 + <FontAwesomeIcon 46 + icon={['far', 'comments']} 47 + style={pal.link as FontAwesomeIconStyle} 48 + size={24} 49 + /> 50 + {threadgate.length ? ( 51 + <FontAwesomeIcon 52 + icon="check" 53 + size={16} 54 + style={pal.link as FontAwesomeIconStyle} 55 + /> 56 + ) : null} 57 + </TouchableOpacity> 58 + ) 59 + } 60 + 61 + const styles = StyleSheet.create({ 62 + button: { 63 + flexDirection: 'row', 64 + alignItems: 'center', 65 + paddingHorizontal: 6, 66 + gap: 4, 67 + }, 68 + })
+4
src/view/com/modals/Modal.tsx
··· 16 16 import * as ServerInputModal from './ServerInput' 17 17 import * as RepostModal from './Repost' 18 18 import * as SelfLabelModal from './SelfLabel' 19 + import * as ThreadgateModal from './Threadgate' 19 20 import * as CreateOrEditListModal from './CreateOrEditList' 20 21 import * as UserAddRemoveListsModal from './UserAddRemoveLists' 21 22 import * as ListAddUserModal from './ListAddRemoveUsers' ··· 127 128 } else if (activeModal?.name === 'self-label') { 128 129 snapPoints = SelfLabelModal.snapPoints 129 130 element = <SelfLabelModal.Component {...activeModal} /> 131 + } else if (activeModal?.name === 'threadgate') { 132 + snapPoints = ThreadgateModal.snapPoints 133 + element = <ThreadgateModal.Component {...activeModal} /> 130 134 } else if (activeModal?.name === 'alt-text-image') { 131 135 snapPoints = AltImageModal.snapPoints 132 136 element = <AltImageModal.Component {...activeModal} />
+3
src/view/com/modals/Modal.web.tsx
··· 18 18 import * as DeleteAccountModal from './DeleteAccount' 19 19 import * as RepostModal from './Repost' 20 20 import * as SelfLabelModal from './SelfLabel' 21 + import * as ThreadgateModal from './Threadgate' 21 22 import * as CropImageModal from './crop-image/CropImage.web' 22 23 import * as AltTextImageModal from './AltImage' 23 24 import * as EditImageModal from './EditImage' ··· 98 99 element = <RepostModal.Component {...modal} /> 99 100 } else if (modal.name === 'self-label') { 100 101 element = <SelfLabelModal.Component {...modal} /> 102 + } else if (modal.name === 'threadgate') { 103 + element = <ThreadgateModal.Component {...modal} /> 101 104 } else if (modal.name === 'change-handle') { 102 105 element = <ChangeHandleModal.Component {...modal} /> 103 106 } else if (modal.name === 'waitlist') {
+204
src/view/com/modals/Threadgate.tsx
··· 1 + import React, {useState} from 'react' 2 + import { 3 + Pressable, 4 + StyleProp, 5 + StyleSheet, 6 + TouchableOpacity, 7 + View, 8 + ViewStyle, 9 + } from 'react-native' 10 + import {Text} from '../util/text/Text' 11 + import {s, colors} from 'lib/styles' 12 + import {usePalette} from 'lib/hooks/usePalette' 13 + import {isWeb} from 'platform/detection' 14 + import {ScrollView} from 'view/com/modals/util' 15 + import {Trans, msg} from '@lingui/macro' 16 + import {useLingui} from '@lingui/react' 17 + import {useModalControls} from '#/state/modals' 18 + import {ThreadgateSetting} from '#/state/queries/threadgate' 19 + import {useMyListsQuery} from '#/state/queries/my-lists' 20 + import isEqual from 'lodash.isequal' 21 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 22 + 23 + export const snapPoints = ['60%'] 24 + 25 + export function Component({ 26 + settings, 27 + onChange, 28 + }: { 29 + settings: ThreadgateSetting[] 30 + onChange: (settings: ThreadgateSetting[]) => void 31 + }) { 32 + const pal = usePalette('default') 33 + const {closeModal} = useModalControls() 34 + const [selected, setSelected] = useState(settings) 35 + const {_} = useLingui() 36 + const {data: lists} = useMyListsQuery('curate') 37 + 38 + const onPressEverybody = () => { 39 + setSelected([]) 40 + onChange([]) 41 + } 42 + 43 + const onPressNobody = () => { 44 + setSelected([{type: 'nobody'}]) 45 + onChange([{type: 'nobody'}]) 46 + } 47 + 48 + const onPressAudience = (setting: ThreadgateSetting) => { 49 + // remove nobody 50 + let newSelected = selected.filter(v => v.type !== 'nobody') 51 + // toggle 52 + const i = newSelected.findIndex(v => isEqual(v, setting)) 53 + if (i === -1) { 54 + newSelected.push(setting) 55 + } else { 56 + newSelected.splice(i, 1) 57 + } 58 + setSelected(newSelected) 59 + onChange(newSelected) 60 + } 61 + 62 + return ( 63 + <View testID="threadgateModal" style={[pal.view, styles.container]}> 64 + <View style={styles.titleSection}> 65 + <Text type="title-lg" style={[pal.text, styles.title]}> 66 + <Trans>Who can reply</Trans> 67 + </Text> 68 + </View> 69 + 70 + <ScrollView> 71 + <Text style={[pal.text, styles.description]}> 72 + Choose "Everybody" or "Nobody" 73 + </Text> 74 + <View style={{flexDirection: 'row', gap: 6, paddingHorizontal: 6}}> 75 + <Selectable 76 + label={_(msg`Everybody`)} 77 + isSelected={selected.length === 0} 78 + onPress={onPressEverybody} 79 + style={{flex: 1}} 80 + /> 81 + <Selectable 82 + label={_(msg`Nobody`)} 83 + isSelected={!!selected.find(v => v.type === 'nobody')} 84 + onPress={onPressNobody} 85 + style={{flex: 1}} 86 + /> 87 + </View> 88 + <Text style={[pal.text, styles.description]}> 89 + Or combine these options: 90 + </Text> 91 + <View style={{flexDirection: 'column', gap: 4, paddingHorizontal: 6}}> 92 + <Selectable 93 + label={_(msg`Mentioned users`)} 94 + isSelected={!!selected.find(v => v.type === 'mention')} 95 + onPress={() => onPressAudience({type: 'mention'})} 96 + /> 97 + <Selectable 98 + label={_(msg`Followed users`)} 99 + isSelected={!!selected.find(v => v.type === 'following')} 100 + onPress={() => onPressAudience({type: 'following'})} 101 + /> 102 + {lists?.length 103 + ? lists.map(list => ( 104 + <Selectable 105 + key={list.uri} 106 + label={_(msg`Users in "${list.name}"`)} 107 + isSelected={ 108 + !!selected.find( 109 + v => v.type === 'list' && v.list === list.uri, 110 + ) 111 + } 112 + onPress={() => 113 + onPressAudience({type: 'list', list: list.uri}) 114 + } 115 + /> 116 + )) 117 + : null} 118 + </View> 119 + </ScrollView> 120 + 121 + <View style={[styles.btnContainer, pal.borderDark]}> 122 + <TouchableOpacity 123 + testID="confirmBtn" 124 + onPress={() => { 125 + closeModal() 126 + }} 127 + style={styles.btn} 128 + accessibilityRole="button" 129 + accessibilityLabel={_(msg`Done`)} 130 + accessibilityHint=""> 131 + <Text style={[s.white, s.bold, s.f18]}> 132 + <Trans>Done</Trans> 133 + </Text> 134 + </TouchableOpacity> 135 + </View> 136 + </View> 137 + ) 138 + } 139 + 140 + function Selectable({ 141 + label, 142 + isSelected, 143 + onPress, 144 + style, 145 + }: { 146 + label: string 147 + isSelected: boolean 148 + onPress: () => void 149 + style?: StyleProp<ViewStyle> 150 + }) { 151 + const pal = usePalette(isSelected ? 'inverted' : 'default') 152 + return ( 153 + <Pressable 154 + onPress={onPress} 155 + accessibilityLabel={label} 156 + accessibilityHint="" 157 + style={[styles.selectable, pal.border, pal.view, style]}> 158 + <Text type="xl" style={[pal.text]}> 159 + {label} 160 + </Text> 161 + {isSelected ? ( 162 + <FontAwesomeIcon icon="check" color={pal.colors.text} size={18} /> 163 + ) : null} 164 + </Pressable> 165 + ) 166 + } 167 + 168 + const styles = StyleSheet.create({ 169 + container: { 170 + flex: 1, 171 + paddingBottom: isWeb ? 0 : 40, 172 + }, 173 + titleSection: { 174 + paddingTop: isWeb ? 0 : 4, 175 + }, 176 + title: { 177 + textAlign: 'center', 178 + fontWeight: '600', 179 + }, 180 + description: { 181 + textAlign: 'center', 182 + paddingVertical: 16, 183 + }, 184 + selectable: { 185 + flexDirection: 'row', 186 + justifyContent: 'space-between', 187 + paddingHorizontal: 18, 188 + paddingVertical: 16, 189 + borderWidth: 1, 190 + borderRadius: 6, 191 + }, 192 + btn: { 193 + flexDirection: 'row', 194 + alignItems: 'center', 195 + justifyContent: 'center', 196 + borderRadius: 32, 197 + padding: 14, 198 + backgroundColor: colors.blue3, 199 + }, 200 + btnContainer: { 201 + paddingTop: 20, 202 + paddingHorizontal: 20, 203 + }, 204 + })
+1 -1
src/view/com/post-thread/PostThread.tsx
··· 468 468 yield PARENT_SPINNER 469 469 } 470 470 yield node 471 - if (node.ctx.isHighlightedPost) { 471 + if (node.ctx.isHighlightedPost && !node.post.viewer?.replyDisabled) { 472 472 yield REPLY_PROMPT 473 473 } 474 474 if (node.replies?.length) {
+151 -139
src/view/com/post-thread/PostThreadItem.tsx
··· 44 44 import {ThreadPost} from '#/state/queries/post-thread' 45 45 import {LabelInfo} from '../util/moderation/LabelInfo' 46 46 import {useSession} from '#/state/session' 47 + import {WhoCanReply} from '../threadgate/WhoCanReply' 47 48 48 49 export function PostThreadItem({ 49 50 post, ··· 441 442 </View> 442 443 </View> 443 444 </Link> 445 + <WhoCanReply post={post} /> 444 446 </> 445 447 ) 446 448 } else { ··· 450 452 const isThreadedChildAdjacentBot = 451 453 isThreadedChild && nextPost?.ctx.depth === depth 452 454 return ( 453 - <PostOuterWrapper 454 - post={post} 455 - depth={depth} 456 - showParentReplyLine={!!showParentReplyLine} 457 - treeView={treeView} 458 - hasPrecedingItem={hasPrecedingItem}> 459 - <PostHider 460 - testID={`postThreadItem-by-${post.author.handle}`} 461 - href={postHref} 462 - style={[pal.view]} 463 - moderation={moderation.content} 464 - iconSize={isThreadedChild ? 26 : 38} 465 - iconStyles={ 466 - isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2} 467 - }> 468 - <PostSandboxWarning /> 455 + <> 456 + <PostOuterWrapper 457 + post={post} 458 + depth={depth} 459 + showParentReplyLine={!!showParentReplyLine} 460 + treeView={treeView} 461 + hasPrecedingItem={hasPrecedingItem}> 462 + <PostHider 463 + testID={`postThreadItem-by-${post.author.handle}`} 464 + href={postHref} 465 + style={[pal.view]} 466 + moderation={moderation.content} 467 + iconSize={isThreadedChild ? 26 : 38} 468 + iconStyles={ 469 + isThreadedChild 470 + ? {marginRight: 4} 471 + : {marginLeft: 2, marginRight: 2} 472 + }> 473 + <PostSandboxWarning /> 469 474 470 - <View 471 - style={{ 472 - flexDirection: 'row', 473 - gap: 10, 474 - paddingLeft: 8, 475 - height: isThreadedChildAdjacentTop ? 8 : 16, 476 - }}> 477 - <View style={{width: 38}}> 478 - {!isThreadedChild && showParentReplyLine && ( 479 - <View 480 - style={[ 481 - styles.replyLine, 482 - { 483 - flexGrow: 1, 484 - backgroundColor: pal.colors.border, 485 - marginBottom: 4, 486 - }, 487 - ]} 488 - /> 489 - )} 490 - </View> 491 - </View> 492 - 493 - <View 494 - style={[ 495 - styles.layout, 496 - { 497 - paddingBottom: 498 - showChildReplyLine && !isThreadedChild 499 - ? 0 500 - : isThreadedChildAdjacentBot 501 - ? 4 502 - : 8, 503 - }, 504 - ]}> 505 - {!isThreadedChild && ( 506 - <View style={styles.layoutAvi}> 507 - <PreviewableUserAvatar 508 - size={38} 509 - did={post.author.did} 510 - handle={post.author.handle} 511 - avatar={post.author.avatar} 512 - moderation={moderation.avatar} 513 - /> 514 - 515 - {showChildReplyLine && ( 475 + <View 476 + style={{ 477 + flexDirection: 'row', 478 + gap: 10, 479 + paddingLeft: 8, 480 + height: isThreadedChildAdjacentTop ? 8 : 16, 481 + }}> 482 + <View style={{width: 38}}> 483 + {!isThreadedChild && showParentReplyLine && ( 516 484 <View 517 485 style={[ 518 486 styles.replyLine, 519 487 { 520 488 flexGrow: 1, 521 489 backgroundColor: pal.colors.border, 522 - marginTop: 4, 490 + marginBottom: 4, 523 491 }, 524 492 ]} 525 493 /> 526 494 )} 527 495 </View> 528 - )} 496 + </View> 529 497 530 - <View style={styles.layoutContent}> 531 - <PostMeta 532 - author={post.author} 533 - authorHasWarning={!!post.author.labels?.length} 534 - timestamp={post.indexedAt} 535 - postHref={postHref} 536 - showAvatar={isThreadedChild} 537 - avatarSize={28} 538 - displayNameType="md-bold" 539 - displayNameStyle={isThreadedChild && s.ml2} 540 - style={isThreadedChild && s.mb2} 541 - /> 542 - <PostAlerts 543 - moderation={moderation.content} 544 - style={styles.alert} 545 - /> 546 - {richText?.text ? ( 547 - <View style={styles.postTextContainer}> 548 - <RichText 549 - type="post-text" 550 - richText={richText} 551 - style={[pal.text, s.flex1]} 552 - lineHeight={1.3} 553 - numberOfLines={limitLines ? MAX_POST_LINES : undefined} 498 + <View 499 + style={[ 500 + styles.layout, 501 + { 502 + paddingBottom: 503 + showChildReplyLine && !isThreadedChild 504 + ? 0 505 + : isThreadedChildAdjacentBot 506 + ? 4 507 + : 8, 508 + }, 509 + ]}> 510 + {!isThreadedChild && ( 511 + <View style={styles.layoutAvi}> 512 + <PreviewableUserAvatar 513 + size={38} 514 + did={post.author.did} 515 + handle={post.author.handle} 516 + avatar={post.author.avatar} 517 + moderation={moderation.avatar} 554 518 /> 519 + 520 + {showChildReplyLine && ( 521 + <View 522 + style={[ 523 + styles.replyLine, 524 + { 525 + flexGrow: 1, 526 + backgroundColor: pal.colors.border, 527 + marginTop: 4, 528 + }, 529 + ]} 530 + /> 531 + )} 555 532 </View> 556 - ) : undefined} 557 - {limitLines ? ( 558 - <TextLink 559 - text="Show More" 560 - style={pal.link} 561 - onPress={onPressShowMore} 562 - href="#" 533 + )} 534 + 535 + <View style={styles.layoutContent}> 536 + <PostMeta 537 + author={post.author} 538 + authorHasWarning={!!post.author.labels?.length} 539 + timestamp={post.indexedAt} 540 + postHref={postHref} 541 + showAvatar={isThreadedChild} 542 + avatarSize={28} 543 + displayNameType="md-bold" 544 + displayNameStyle={isThreadedChild && s.ml2} 545 + style={isThreadedChild && s.mb2} 563 546 /> 564 - ) : undefined} 565 - {post.embed && ( 566 - <ContentHider 567 - style={styles.contentHider} 568 - moderation={moderation.embed} 569 - moderationDecisions={moderation.decisions} 570 - ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} 571 - ignoreQuoteDecisions> 572 - <PostEmbeds 573 - embed={post.embed} 547 + <PostAlerts 548 + moderation={moderation.content} 549 + style={styles.alert} 550 + /> 551 + {richText?.text ? ( 552 + <View style={styles.postTextContainer}> 553 + <RichText 554 + type="post-text" 555 + richText={richText} 556 + style={[pal.text, s.flex1]} 557 + lineHeight={1.3} 558 + numberOfLines={limitLines ? MAX_POST_LINES : undefined} 559 + /> 560 + </View> 561 + ) : undefined} 562 + {limitLines ? ( 563 + <TextLink 564 + text="Show More" 565 + style={pal.link} 566 + onPress={onPressShowMore} 567 + href="#" 568 + /> 569 + ) : undefined} 570 + {post.embed && ( 571 + <ContentHider 572 + style={styles.contentHider} 574 573 moderation={moderation.embed} 575 574 moderationDecisions={moderation.decisions} 576 - /> 577 - </ContentHider> 578 - )} 579 - <PostCtrls 580 - post={post} 581 - record={record} 582 - onPressReply={onPressReply} 583 - /> 575 + ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} 576 + ignoreQuoteDecisions> 577 + <PostEmbeds 578 + embed={post.embed} 579 + moderation={moderation.embed} 580 + moderationDecisions={moderation.decisions} 581 + /> 582 + </ContentHider> 583 + )} 584 + <PostCtrls 585 + post={post} 586 + record={record} 587 + onPressReply={onPressReply} 588 + /> 589 + </View> 584 590 </View> 585 - </View> 586 - {hasMore ? ( 587 - <Link 588 - style={[ 589 - styles.loadMore, 590 - { 591 - paddingLeft: treeView ? 8 : 70, 592 - paddingTop: 0, 593 - paddingBottom: treeView ? 4 : 12, 594 - }, 595 - ]} 596 - href={postHref} 597 - title={itemTitle} 598 - noFeedback> 599 - <Text type="sm-medium" style={pal.textLight}> 600 - More 601 - </Text> 602 - <FontAwesomeIcon 603 - icon="angle-right" 604 - color={pal.colors.textLight} 605 - size={14} 606 - /> 607 - </Link> 608 - ) : undefined} 609 - </PostHider> 610 - </PostOuterWrapper> 591 + {hasMore ? ( 592 + <Link 593 + style={[ 594 + styles.loadMore, 595 + { 596 + paddingLeft: treeView ? 8 : 70, 597 + paddingTop: 0, 598 + paddingBottom: treeView ? 4 : 12, 599 + }, 600 + ]} 601 + href={postHref} 602 + title={itemTitle} 603 + noFeedback> 604 + <Text type="sm-medium" style={pal.textLight}> 605 + More 606 + </Text> 607 + <FontAwesomeIcon 608 + icon="angle-right" 609 + color={pal.colors.textLight} 610 + size={14} 611 + /> 612 + </Link> 613 + ) : undefined} 614 + </PostHider> 615 + </PostOuterWrapper> 616 + <WhoCanReply 617 + post={post} 618 + style={{ 619 + marginTop: 4, 620 + }} 621 + /> 622 + </> 611 623 ) 612 624 } 613 625 }
+183
src/view/com/threadgate/WhoCanReply.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, View, ViewStyle} from 'react-native' 3 + import { 4 + AppBskyFeedDefs, 5 + AppBskyFeedThreadgate, 6 + AppBskyGraphDefs, 7 + AtUri, 8 + } from '@atproto/api' 9 + import {Trans} from '@lingui/macro' 10 + import {usePalette} from '#/lib/hooks/usePalette' 11 + import {Text} from '../util/text/Text' 12 + import {TextLink} from '../util/Link' 13 + import {makeProfileLink, makeListLink} from '#/lib/routes/links' 14 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 15 + import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' 16 + import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 17 + 18 + import {colors} from '#/lib/styles' 19 + 20 + export function WhoCanReply({ 21 + post, 22 + style, 23 + }: { 24 + post: AppBskyFeedDefs.PostView 25 + style?: StyleProp<ViewStyle> 26 + }) { 27 + const pal = usePalette('default') 28 + const {isMobile} = useWebMediaQueries() 29 + const containerStyles = useColorSchemeStyle( 30 + { 31 + borderColor: pal.colors.unreadNotifBorder, 32 + backgroundColor: pal.colors.unreadNotifBg, 33 + }, 34 + { 35 + borderColor: pal.colors.unreadNotifBorder, 36 + backgroundColor: pal.colors.unreadNotifBg, 37 + }, 38 + ) 39 + const iconStyles = useColorSchemeStyle( 40 + { 41 + backgroundColor: colors.blue3, 42 + }, 43 + { 44 + backgroundColor: colors.blue3, 45 + }, 46 + ) 47 + const textStyles = useColorSchemeStyle( 48 + {color: colors.gray7}, 49 + {color: colors.blue1}, 50 + ) 51 + const record = React.useMemo( 52 + () => 53 + post.threadgate && 54 + AppBskyFeedThreadgate.isRecord(post.threadgate.record) && 55 + AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success 56 + ? post.threadgate.record 57 + : null, 58 + [post], 59 + ) 60 + if (record) { 61 + return ( 62 + <View 63 + style={[ 64 + { 65 + flexDirection: 'row', 66 + alignItems: 'center', 67 + gap: isMobile ? 8 : 10, 68 + paddingHorizontal: isMobile ? 16 : 18, 69 + paddingVertical: 12, 70 + borderWidth: 1, 71 + borderLeftWidth: isMobile ? 0 : 1, 72 + borderRightWidth: isMobile ? 0 : 1, 73 + }, 74 + containerStyles, 75 + style, 76 + ]}> 77 + <View 78 + style={[ 79 + { 80 + flexDirection: 'row', 81 + alignItems: 'center', 82 + justifyContent: 'center', 83 + width: 32, 84 + height: 32, 85 + borderRadius: 19, 86 + }, 87 + iconStyles, 88 + ]}> 89 + <FontAwesomeIcon 90 + icon={['far', 'comments']} 91 + size={16} 92 + color={'#fff'} 93 + /> 94 + </View> 95 + <View style={{flex: 1}}> 96 + <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}> 97 + {!record.allow?.length ? ( 98 + <Trans>Replies to this thread are disabled</Trans> 99 + ) : ( 100 + <Trans> 101 + Only{' '} 102 + {record.allow.map((rule, i) => ( 103 + <> 104 + <Rule 105 + key={`rule-${i}`} 106 + rule={rule} 107 + post={post} 108 + lists={post.threadgate!.lists} 109 + /> 110 + <Separator 111 + key={`sep-${i}`} 112 + i={i} 113 + length={record.allow!.length} 114 + /> 115 + </> 116 + ))}{' '} 117 + can reply. 118 + </Trans> 119 + )} 120 + </Text> 121 + </View> 122 + </View> 123 + ) 124 + } 125 + return null 126 + } 127 + 128 + function Rule({ 129 + rule, 130 + post, 131 + lists, 132 + }: { 133 + rule: any 134 + post: AppBskyFeedDefs.PostView 135 + lists: AppBskyGraphDefs.ListViewBasic[] | undefined 136 + }) { 137 + const pal = usePalette('default') 138 + if (AppBskyFeedThreadgate.isMentionRule(rule)) { 139 + return <Trans>mentioned users</Trans> 140 + } 141 + if (AppBskyFeedThreadgate.isFollowingRule(rule)) { 142 + return ( 143 + <Trans> 144 + users followed by{' '} 145 + <TextLink 146 + href={makeProfileLink(post.author)} 147 + text={`@${post.author.handle}`} 148 + style={pal.link} 149 + /> 150 + </Trans> 151 + ) 152 + } 153 + if (AppBskyFeedThreadgate.isListRule(rule)) { 154 + const list = lists?.find(l => l.uri === rule.list) 155 + if (list) { 156 + const listUrip = new AtUri(list.uri) 157 + return ( 158 + <Trans> 159 + <TextLink 160 + href={makeListLink(listUrip.hostname, listUrip.rkey)} 161 + text={list.name} 162 + style={pal.link} 163 + />{' '} 164 + members 165 + </Trans> 166 + ) 167 + } 168 + } 169 + } 170 + 171 + function Separator({i, length}: {i: number; length: number}) { 172 + if (length < 2 || i === length - 1) { 173 + return null 174 + } 175 + if (i === length - 2) { 176 + return ( 177 + <> 178 + {length > 2 ? ',' : ''} <Trans>and</Trans>{' '} 179 + </> 180 + ) 181 + } 182 + return <>, </> 183 + }
+9 -2
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 108 108 <View style={[styles.ctrls, style]}> 109 109 <TouchableOpacity 110 110 testID="replyBtn" 111 - style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]} 111 + style={[ 112 + styles.ctrl, 113 + !big && styles.ctrlPad, 114 + {paddingLeft: 0}, 115 + post.viewer?.replyDisabled ? {opacity: 0.5} : undefined, 116 + ]} 112 117 onPress={() => { 113 - requireAuth(() => onPressReply()) 118 + if (!post.viewer?.replyDisabled) { 119 + requireAuth(() => onPressReply()) 120 + } 114 121 }} 115 122 accessibilityRole="button" 116 123 accessibilityLabel={`Reply (${post.replyCount} ${