Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Composer - replace threadgate modal with alf dialog (#4329)

* replace threadgate modal with alf dialog

* add accessibility to selectable

* add aria

* hide spinner once fetched

* add `hasOpenDialogs` value to context

* remove state

* Rm loading state

* Update the threadgate dialog button theming

* Factor out the threadgate editor and add editing to post views

* Mark messages for localization

* Use colors from mute dialog

* Remove unnecessary effect

* Reset state on dialog dismiss

* Clearer CTA

* Fix bugs

* Scope keyboard fix

* Rm getAreDialogsActive (no longer needed)

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Co-authored-by: Paul Frazee <pfrazee@gmail.com>

authored by

Samuel Newman
Dan Abramov
Paul Frazee
and committed by
GitHub
29aaf09a e0ac7d5b

+334 -314
+4 -1
src/components/Dialog/index.web.tsx
··· 88 88 if (!isOpen) return 89 89 90 90 function handler(e: KeyboardEvent) { 91 - if (e.key === 'Escape') close() 91 + if (e.key === 'Escape') { 92 + e.stopPropagation() 93 + close() 94 + } 92 95 } 93 96 94 97 document.addEventListener('keydown', handler)
+82 -68
src/components/WhoCanReply.tsx
··· 17 17 import {makeListLink, makeProfileLink} from '#/lib/routes/links' 18 18 import {logger} from '#/logger' 19 19 import {isNative} from '#/platform/detection' 20 - import {useModalControls} from '#/state/modals' 21 20 import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread' 22 21 import { 23 22 ThreadgateSetting, ··· 34 33 import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 35 34 import {Text} from '#/components/Typography' 36 35 import {TextLink} from '../view/com/util/Link' 36 + import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor' 37 37 import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' 38 38 39 39 interface WhoCanReplyProps { ··· 46 46 const {_} = useLingui() 47 47 const t = useTheme() 48 48 const infoDialogControl = useDialogControl() 49 - const {settings, isRootPost, onPressEdit} = useWhoCanReply(post) 49 + const editDialogControl = useDialogControl() 50 + const agent = useAgent() 51 + const queryClient = useQueryClient() 52 + 53 + const settings = React.useMemo( 54 + () => threadgateViewToSettings(post.threadgate), 55 + [post], 56 + ) 57 + const isRootPost = !('reply' in post.record) 50 58 51 59 if (!isRootPost) { 52 60 return null ··· 63 71 ? _(msg`Replies disabled`) 64 72 : _(msg`Some people can reply`) 65 73 74 + const onPressEdit = () => { 75 + if (isNative && Keyboard.isVisible()) { 76 + Keyboard.dismiss() 77 + } 78 + if (isThreadAuthor) { 79 + editDialogControl.open() 80 + } else { 81 + infoDialogControl.open() 82 + } 83 + } 84 + 85 + const onEditConfirm = async (newSettings: ThreadgateSetting[]) => { 86 + if (JSON.stringify(settings) === JSON.stringify(newSettings)) { 87 + return 88 + } 89 + try { 90 + if (newSettings.length) { 91 + await createThreadgate(agent, post.uri, newSettings) 92 + } else { 93 + await agent.api.com.atproto.repo.deleteRecord({ 94 + repo: agent.session!.did, 95 + collection: 'app.bsky.feed.threadgate', 96 + rkey: new AtUri(post.uri).rkey, 97 + }) 98 + } 99 + await whenAppViewReady(agent, post.uri, res => { 100 + const thread = res.data.thread 101 + if (AppBskyFeedDefs.isThreadViewPost(thread)) { 102 + const fetchedSettings = threadgateViewToSettings( 103 + thread.post.threadgate, 104 + ) 105 + return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings) 106 + } 107 + return false 108 + }) 109 + Toast.show(_(msg`Thread settings updated`)) 110 + queryClient.invalidateQueries({ 111 + queryKey: [POST_THREAD_RQKEY_ROOT], 112 + }) 113 + } catch (err) { 114 + Toast.show( 115 + _( 116 + msg`There was an issue. Please check your internet connection and try again.`, 117 + ), 118 + ) 119 + logger.error('Failed to edit threadgate', {message: err}) 120 + } 121 + } 122 + 66 123 return ( 67 124 <> 68 125 <Button ··· 93 150 </View> 94 151 )} 95 152 </Button> 96 - <WhoCanReplyDialog control={infoDialogControl} post={post} /> 153 + <WhoCanReplyDialog 154 + control={infoDialogControl} 155 + post={post} 156 + settings={settings} 157 + /> 158 + {isThreadAuthor && ( 159 + <ThreadgateEditorDialog 160 + control={editDialogControl} 161 + threadgate={settings} 162 + onConfirm={onEditConfirm} 163 + /> 164 + )} 97 165 </> 98 166 ) 99 167 } ··· 113 181 return <IconComponent fill={color} width={width} /> 114 182 } 115 183 116 - export function WhoCanReplyDialog({ 184 + function WhoCanReplyDialog({ 117 185 control, 118 186 post, 187 + settings, 119 188 }: { 120 189 control: Dialog.DialogControlProps 121 190 post: AppBskyFeedDefs.PostView 191 + settings: ThreadgateSetting[] 122 192 }) { 123 193 return ( 124 194 <Dialog.Outer control={control}> 125 195 <Dialog.Handle /> 126 - <WhoCanReplyDialogInner post={post} /> 196 + <WhoCanReplyDialogInner post={post} settings={settings} /> 127 197 </Dialog.Outer> 128 198 ) 129 199 } 130 200 131 - function WhoCanReplyDialogInner({post}: {post: AppBskyFeedDefs.PostView}) { 201 + function WhoCanReplyDialogInner({ 202 + post, 203 + settings, 204 + }: { 205 + post: AppBskyFeedDefs.PostView 206 + settings: ThreadgateSetting[] 207 + }) { 132 208 const {_} = useLingui() 133 - const {settings} = useWhoCanReply(post) 134 209 return ( 135 210 <Dialog.ScrollableInner 136 211 label={_(msg`Who can reply dialog`)} ··· 243 318 ) 244 319 } 245 320 return <>, </> 246 - } 247 - 248 - function useWhoCanReply(post: AppBskyFeedDefs.PostView) { 249 - const agent = useAgent() 250 - const queryClient = useQueryClient() 251 - const {openModal} = useModalControls() 252 - 253 - const settings = React.useMemo( 254 - () => threadgateViewToSettings(post.threadgate), 255 - [post], 256 - ) 257 - const isRootPost = !('reply' in post.record) 258 - 259 - const onPressEdit = () => { 260 - if (isNative && Keyboard.isVisible()) { 261 - Keyboard.dismiss() 262 - } 263 - openModal({ 264 - name: 'threadgate', 265 - settings, 266 - async onConfirm(newSettings: ThreadgateSetting[]) { 267 - if (JSON.stringify(settings) === JSON.stringify(newSettings)) { 268 - return 269 - } 270 - try { 271 - if (newSettings.length) { 272 - await createThreadgate(agent, post.uri, newSettings) 273 - } else { 274 - await agent.api.com.atproto.repo.deleteRecord({ 275 - repo: agent.session!.did, 276 - collection: 'app.bsky.feed.threadgate', 277 - rkey: new AtUri(post.uri).rkey, 278 - }) 279 - } 280 - await whenAppViewReady(agent, post.uri, res => { 281 - const thread = res.data.thread 282 - if (AppBskyFeedDefs.isThreadViewPost(thread)) { 283 - const fetchedSettings = threadgateViewToSettings( 284 - thread.post.threadgate, 285 - ) 286 - return ( 287 - JSON.stringify(fetchedSettings) === JSON.stringify(newSettings) 288 - ) 289 - } 290 - return false 291 - }) 292 - Toast.show('Thread settings updated') 293 - queryClient.invalidateQueries({ 294 - queryKey: [POST_THREAD_RQKEY_ROOT], 295 - }) 296 - } catch (err) { 297 - Toast.show( 298 - 'There was an issue. Please check your internet connection and try again.', 299 - ) 300 - logger.error('Failed to edit threadgate', {message: err}) 301 - } 302 - }, 303 - }) 304 - } 305 - 306 - return {settings, isRootPost, onPressEdit} 307 321 } 308 322 309 323 async function whenAppViewReady(
+1
src/components/dialogs/EmbedConsent.tsx
··· 113 113 </ButtonText> 114 114 </Button> 115 115 </View> 116 + <Dialog.Close /> 116 117 </Dialog.ScrollableInner> 117 118 </Dialog.Outer> 118 119 )
+218
src/components/dialogs/ThreadgateEditor.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, View, ViewStyle} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import isEqual from 'lodash.isequal' 6 + 7 + import {useMyListsQuery} from '#/state/queries/my-lists' 8 + import {ThreadgateSetting} from '#/state/queries/threadgate' 9 + import {atoms as a, useTheme} from '#/alf' 10 + import {Button, ButtonText} from '#/components/Button' 11 + import * as Dialog from '#/components/Dialog' 12 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 13 + import {Text} from '#/components/Typography' 14 + 15 + interface ThreadgateEditorDialogProps { 16 + control: Dialog.DialogControlProps 17 + threadgate: ThreadgateSetting[] 18 + onChange?: (v: ThreadgateSetting[]) => void 19 + onConfirm?: (v: ThreadgateSetting[]) => void 20 + } 21 + 22 + export function ThreadgateEditorDialog({ 23 + control, 24 + threadgate, 25 + onChange, 26 + onConfirm, 27 + }: ThreadgateEditorDialogProps) { 28 + return ( 29 + <Dialog.Outer control={control}> 30 + <Dialog.Handle /> 31 + <DialogContent 32 + seedThreadgate={threadgate} 33 + onChange={onChange} 34 + onConfirm={onConfirm} 35 + /> 36 + </Dialog.Outer> 37 + ) 38 + } 39 + 40 + function DialogContent({ 41 + seedThreadgate, 42 + onChange, 43 + onConfirm, 44 + }: { 45 + seedThreadgate: ThreadgateSetting[] 46 + onChange?: (v: ThreadgateSetting[]) => void 47 + onConfirm?: (v: ThreadgateSetting[]) => void 48 + }) { 49 + const {_} = useLingui() 50 + const control = Dialog.useDialogContext() 51 + const {data: lists} = useMyListsQuery('curate') 52 + const [draft, setDraft] = React.useState(seedThreadgate) 53 + 54 + const [prevSeedThreadgate, setPrevSeedThreadgate] = 55 + React.useState(seedThreadgate) 56 + if (seedThreadgate !== prevSeedThreadgate) { 57 + // New data flowed from above (e.g. due to update coming through). 58 + setPrevSeedThreadgate(seedThreadgate) 59 + setDraft(seedThreadgate) // Reset draft. 60 + } 61 + 62 + function updateThreadgate(nextThreadgate: ThreadgateSetting[]) { 63 + setDraft(nextThreadgate) 64 + onChange?.(nextThreadgate) 65 + } 66 + 67 + const onPressEverybody = () => { 68 + updateThreadgate([]) 69 + } 70 + 71 + const onPressNobody = () => { 72 + updateThreadgate([{type: 'nobody'}]) 73 + } 74 + 75 + const onPressAudience = (setting: ThreadgateSetting) => { 76 + // remove nobody 77 + let newSelected = draft.filter(v => v.type !== 'nobody') 78 + // toggle 79 + const i = newSelected.findIndex(v => isEqual(v, setting)) 80 + if (i === -1) { 81 + newSelected.push(setting) 82 + } else { 83 + newSelected.splice(i, 1) 84 + } 85 + updateThreadgate(newSelected) 86 + } 87 + 88 + const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`) 89 + return ( 90 + <Dialog.ScrollableInner 91 + label={_(msg`Choose who can reply`)} 92 + style={[{maxWidth: 500}, a.w_full]}> 93 + <View style={[a.flex_1, a.gap_md]}> 94 + <Text style={[a.text_2xl, a.font_bold]}> 95 + <Trans>Chose who can reply</Trans> 96 + </Text> 97 + <Text style={a.mt_xs}> 98 + <Trans>Either choose "Everybody" or "Nobody"</Trans> 99 + </Text> 100 + <View style={[a.flex_row, a.gap_sm]}> 101 + <Selectable 102 + label={_(msg`Everybody`)} 103 + isSelected={draft.length === 0} 104 + onPress={onPressEverybody} 105 + style={{flex: 1}} 106 + /> 107 + <Selectable 108 + label={_(msg`Nobody`)} 109 + isSelected={!!draft.find(v => v.type === 'nobody')} 110 + onPress={onPressNobody} 111 + style={{flex: 1}} 112 + /> 113 + </View> 114 + <Text style={a.mt_md}> 115 + <Trans>Or combine these options:</Trans> 116 + </Text> 117 + <View style={[a.gap_sm]}> 118 + <Selectable 119 + label={_(msg`Mentioned users`)} 120 + isSelected={!!draft.find(v => v.type === 'mention')} 121 + onPress={() => onPressAudience({type: 'mention'})} 122 + /> 123 + <Selectable 124 + label={_(msg`Followed users`)} 125 + isSelected={!!draft.find(v => v.type === 'following')} 126 + onPress={() => onPressAudience({type: 'following'})} 127 + /> 128 + {lists && lists.length > 0 129 + ? lists.map(list => ( 130 + <Selectable 131 + key={list.uri} 132 + label={_(msg`Users in "${list.name}"`)} 133 + isSelected={ 134 + !!draft.find(v => v.type === 'list' && v.list === list.uri) 135 + } 136 + onPress={() => 137 + onPressAudience({type: 'list', list: list.uri}) 138 + } 139 + /> 140 + )) 141 + : // No loading states to avoid jumps for the common case (no lists) 142 + null} 143 + </View> 144 + </View> 145 + <Button 146 + label={doneLabel} 147 + onPress={() => { 148 + control.close() 149 + onConfirm?.(draft) 150 + }} 151 + onAccessibilityEscape={control.close} 152 + color="primary" 153 + size="medium" 154 + variant="solid" 155 + style={a.mt_xl}> 156 + <ButtonText>{doneLabel}</ButtonText> 157 + </Button> 158 + <Dialog.Close /> 159 + </Dialog.ScrollableInner> 160 + ) 161 + } 162 + 163 + function Selectable({ 164 + label, 165 + isSelected, 166 + onPress, 167 + style, 168 + }: { 169 + label: string 170 + isSelected: boolean 171 + onPress: () => void 172 + style?: StyleProp<ViewStyle> 173 + }) { 174 + const t = useTheme() 175 + return ( 176 + <Button 177 + onPress={onPress} 178 + label={label} 179 + accessibilityHint="Select this option" 180 + accessibilityRole="checkbox" 181 + aria-checked={isSelected} 182 + accessibilityState={{ 183 + checked: isSelected, 184 + }} 185 + style={a.flex_1}> 186 + {({hovered, focused}) => ( 187 + <View 188 + style={[ 189 + a.flex_1, 190 + a.flex_row, 191 + a.align_center, 192 + a.justify_between, 193 + a.rounded_sm, 194 + a.p_md, 195 + {height: 40}, // for consistency with checkmark icon visible or not 196 + t.atoms.bg_contrast_50, 197 + (hovered || focused) && t.atoms.bg_contrast_100, 198 + isSelected && { 199 + backgroundColor: 200 + t.name === 'light' 201 + ? t.palette.primary_50 202 + : t.palette.primary_975, 203 + }, 204 + style, 205 + ]}> 206 + <Text style={[a.text_sm, isSelected && a.font_semibold]}> 207 + {label} 208 + </Text> 209 + {isSelected ? ( 210 + <Check size="sm" fill={t.palette.primary_500} /> 211 + ) : ( 212 + <View /> 213 + )} 214 + </View> 215 + )} 216 + </Button> 217 + ) 218 + }
-9
src/state/modals/index.tsx
··· 5 5 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 6 6 import {GalleryModel} from '#/state/models/media/gallery' 7 7 import {ImageModel} from '#/state/models/media/image' 8 - import {ThreadgateSetting} from '../queries/threadgate' 9 8 10 9 export interface EditProfileModal { 11 10 name: 'edit-profile' ··· 65 64 labels: string[] 66 65 hasMedia: boolean 67 66 onChange: (labels: string[]) => void 68 - } 69 - 70 - export interface ThreadgateModal { 71 - name: 'threadgate' 72 - settings: ThreadgateSetting[] 73 - onChange?: (settings: ThreadgateSetting[]) => void 74 - onConfirm?: (settings: ThreadgateSetting[]) => void 75 67 } 76 68 77 69 export interface ChangeHandleModal { ··· 149 141 | CropImageModal 150 142 | EditImageModal 151 143 | SelfLabelModal 152 - | ThreadgateModal 153 144 154 145 // Bluesky access 155 146 | WaitlistModal
+29 -21
src/view/com/composer/threadgate/ThreadgateBtn.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {isNative} from '#/platform/detection' 8 - import {useModalControls} from '#/state/modals' 9 8 import {ThreadgateSetting} from '#/state/queries/threadgate' 10 9 import {useAnalytics} from 'lib/analytics/analytics' 11 10 import {atoms as a, useTheme} from '#/alf' 12 11 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 + import * as Dialog from '#/components/Dialog' 13 + import {ThreadgateEditorDialog} from '#/components/dialogs/ThreadgateEditor' 13 14 import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 14 15 import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' 15 16 import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' ··· 26 27 const {track} = useAnalytics() 27 28 const {_} = useLingui() 28 29 const t = useTheme() 29 - const {openModal} = useModalControls() 30 + const control = Dialog.useDialogControl() 30 31 31 32 const onPress = () => { 32 33 track('Composer:ThreadgateOpened') 33 34 if (isNative && Keyboard.isVisible()) { 34 35 Keyboard.dismiss() 35 36 } 36 - openModal({ 37 - name: 'threadgate', 38 - settings: threadgate, 39 - onChange, 40 - }) 37 + 38 + control.open() 41 39 } 42 40 43 41 const isEverybody = threadgate.length === 0 ··· 49 47 : _(msg`Some people can reply`) 50 48 51 49 return ( 52 - <Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}> 53 - <Button 54 - variant="solid" 55 - color="secondary" 56 - size="xsmall" 57 - testID="openReplyGateButton" 58 - onPress={onPress} 59 - label={label}> 60 - <ButtonIcon 61 - icon={isEverybody ? Earth : isNobody ? CircleBanSign : Group} 62 - /> 63 - <ButtonText>{label}</ButtonText> 64 - </Button> 65 - </Animated.View> 50 + <> 51 + <Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}> 52 + <Button 53 + variant="solid" 54 + color="secondary" 55 + size="xsmall" 56 + testID="openReplyGateButton" 57 + onPress={onPress} 58 + label={label} 59 + accessibilityHint={_( 60 + msg`Opens a dialog to choose who can reply to this thread`, 61 + )}> 62 + <ButtonIcon 63 + icon={isEverybody ? Earth : isNobody ? CircleBanSign : Group} 64 + /> 65 + <ButtonText>{label}</ButtonText> 66 + </Button> 67 + </Animated.View> 68 + <ThreadgateEditorDialog 69 + control={control} 70 + threadgate={threadgate} 71 + onChange={onChange} 72 + /> 73 + </> 66 74 ) 67 75 }
-4
src/view/com/modals/Modal.tsx
··· 23 23 import * as LinkWarningModal from './LinkWarning' 24 24 import * as ListAddUserModal from './ListAddRemoveUsers' 25 25 import * as SelfLabelModal from './SelfLabel' 26 - import * as ThreadgateModal from './Threadgate' 27 26 import * as UserAddRemoveListsModal from './UserAddRemoveLists' 28 27 import * as VerifyEmailModal from './VerifyEmail' 29 28 ··· 76 75 } else if (activeModal?.name === 'self-label') { 77 76 snapPoints = SelfLabelModal.snapPoints 78 77 element = <SelfLabelModal.Component {...activeModal} /> 79 - } else if (activeModal?.name === 'threadgate') { 80 - snapPoints = ThreadgateModal.snapPoints 81 - element = <ThreadgateModal.Component {...activeModal} /> 82 78 } else if (activeModal?.name === 'alt-text-image') { 83 79 snapPoints = AltImageModal.snapPoints 84 80 element = <AltImageModal.Component {...activeModal} />
-3
src/view/com/modals/Modal.web.tsx
··· 23 23 import * as LinkWarningModal from './LinkWarning' 24 24 import * as ListAddUserModal from './ListAddRemoveUsers' 25 25 import * as SelfLabelModal from './SelfLabel' 26 - import * as ThreadgateModal from './Threadgate' 27 26 import * as UserAddRemoveLists from './UserAddRemoveLists' 28 27 import * as VerifyEmailModal from './VerifyEmail' 29 28 ··· 84 83 element = <DeleteAccountModal.Component /> 85 84 } else if (modal.name === 'self-label') { 86 85 element = <SelfLabelModal.Component {...modal} /> 87 - } else if (modal.name === 'threadgate') { 88 - element = <ThreadgateModal.Component {...modal} /> 89 86 } else if (modal.name === 'change-handle') { 90 87 element = <ChangeHandleModal.Component {...modal} /> 91 88 } else if (modal.name === 'invite-codes') {
-208
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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 11 - import {msg, Trans} from '@lingui/macro' 12 - import {useLingui} from '@lingui/react' 13 - import isEqual from 'lodash.isequal' 14 - 15 - import {useModalControls} from '#/state/modals' 16 - import {useMyListsQuery} from '#/state/queries/my-lists' 17 - import {ThreadgateSetting} from '#/state/queries/threadgate' 18 - import {usePalette} from 'lib/hooks/usePalette' 19 - import {colors, s} from 'lib/styles' 20 - import {isWeb} from 'platform/detection' 21 - import {ScrollView} from 'view/com/modals/util' 22 - import {Text} from '../util/text/Text' 23 - 24 - export const snapPoints = ['60%'] 25 - 26 - export function Component({ 27 - settings, 28 - onChange, 29 - onConfirm, 30 - }: { 31 - settings: ThreadgateSetting[] 32 - onChange?: (settings: ThreadgateSetting[]) => void 33 - onConfirm?: (settings: ThreadgateSetting[]) => void 34 - }) { 35 - const pal = usePalette('default') 36 - const {closeModal} = useModalControls() 37 - const [selected, setSelected] = useState(settings) 38 - const {_} = useLingui() 39 - const {data: lists} = useMyListsQuery('curate') 40 - 41 - const onPressEverybody = () => { 42 - setSelected([]) 43 - onChange?.([]) 44 - } 45 - 46 - const onPressNobody = () => { 47 - setSelected([{type: 'nobody'}]) 48 - onChange?.([{type: 'nobody'}]) 49 - } 50 - 51 - const onPressAudience = (setting: ThreadgateSetting) => { 52 - // remove nobody 53 - let newSelected = selected.filter(v => v.type !== 'nobody') 54 - // toggle 55 - const i = newSelected.findIndex(v => isEqual(v, setting)) 56 - if (i === -1) { 57 - newSelected.push(setting) 58 - } else { 59 - newSelected.splice(i, 1) 60 - } 61 - setSelected(newSelected) 62 - onChange?.(newSelected) 63 - } 64 - 65 - return ( 66 - <View testID="threadgateModal" style={[pal.view, styles.container]}> 67 - <View style={styles.titleSection}> 68 - <Text type="title-lg" style={[pal.text, styles.title]}> 69 - <Trans>Who can reply</Trans> 70 - </Text> 71 - </View> 72 - 73 - <ScrollView> 74 - <Text style={[pal.text, styles.description]}> 75 - <Trans>Choose "Everybody" or "Nobody"</Trans> 76 - </Text> 77 - <View style={{flexDirection: 'row', gap: 6, paddingHorizontal: 6}}> 78 - <Selectable 79 - label={_(msg`Everybody`)} 80 - isSelected={selected.length === 0} 81 - onPress={onPressEverybody} 82 - style={{flex: 1}} 83 - /> 84 - <Selectable 85 - label={_(msg`Nobody`)} 86 - isSelected={!!selected.find(v => v.type === 'nobody')} 87 - onPress={onPressNobody} 88 - style={{flex: 1}} 89 - /> 90 - </View> 91 - <Text style={[pal.text, styles.description]}> 92 - <Trans>Or combine these options:</Trans> 93 - </Text> 94 - <View style={{flexDirection: 'column', gap: 4, paddingHorizontal: 6}}> 95 - <Selectable 96 - label={_(msg`Mentioned users`)} 97 - isSelected={!!selected.find(v => v.type === 'mention')} 98 - onPress={() => onPressAudience({type: 'mention'})} 99 - /> 100 - <Selectable 101 - label={_(msg`Followed users`)} 102 - isSelected={!!selected.find(v => v.type === 'following')} 103 - onPress={() => onPressAudience({type: 'following'})} 104 - /> 105 - {lists?.length 106 - ? lists.map(list => ( 107 - <Selectable 108 - key={list.uri} 109 - label={_(msg`Users in "${list.name}"`)} 110 - isSelected={ 111 - !!selected.find( 112 - v => v.type === 'list' && v.list === list.uri, 113 - ) 114 - } 115 - onPress={() => 116 - onPressAudience({type: 'list', list: list.uri}) 117 - } 118 - /> 119 - )) 120 - : null} 121 - </View> 122 - </ScrollView> 123 - 124 - <View style={[styles.btnContainer, pal.borderDark]}> 125 - <TouchableOpacity 126 - testID="confirmBtn" 127 - onPress={() => { 128 - closeModal() 129 - onConfirm?.(selected) 130 - }} 131 - style={styles.btn} 132 - accessibilityRole="button" 133 - accessibilityLabel={_(msg({message: `Done`, context: 'action'}))} 134 - accessibilityHint=""> 135 - <Text style={[s.white, s.bold, s.f18]}> 136 - <Trans context="action">Done</Trans> 137 - </Text> 138 - </TouchableOpacity> 139 - </View> 140 - </View> 141 - ) 142 - } 143 - 144 - function Selectable({ 145 - label, 146 - isSelected, 147 - onPress, 148 - style, 149 - }: { 150 - label: string 151 - isSelected: boolean 152 - onPress: () => void 153 - style?: StyleProp<ViewStyle> 154 - }) { 155 - const pal = usePalette(isSelected ? 'inverted' : 'default') 156 - return ( 157 - <Pressable 158 - onPress={onPress} 159 - accessibilityLabel={label} 160 - accessibilityHint="" 161 - style={[styles.selectable, pal.border, pal.view, style]}> 162 - <Text type="lg" style={[pal.text]}> 163 - {label} 164 - </Text> 165 - {isSelected ? ( 166 - <FontAwesomeIcon icon="check" color={pal.colors.text} size={18} /> 167 - ) : null} 168 - </Pressable> 169 - ) 170 - } 171 - 172 - const styles = StyleSheet.create({ 173 - container: { 174 - flex: 1, 175 - paddingBottom: isWeb ? 0 : 40, 176 - }, 177 - titleSection: { 178 - paddingTop: isWeb ? 0 : 4, 179 - }, 180 - title: { 181 - textAlign: 'center', 182 - fontWeight: '600', 183 - }, 184 - description: { 185 - textAlign: 'center', 186 - paddingVertical: 16, 187 - }, 188 - selectable: { 189 - flexDirection: 'row', 190 - justifyContent: 'space-between', 191 - paddingHorizontal: 18, 192 - paddingVertical: 16, 193 - borderWidth: 1, 194 - borderRadius: 6, 195 - }, 196 - btn: { 197 - flexDirection: 'row', 198 - alignItems: 'center', 199 - justifyContent: 'center', 200 - borderRadius: 32, 201 - padding: 14, 202 - backgroundColor: colors.blue3, 203 - }, 204 - btnContainer: { 205 - paddingTop: 20, 206 - paddingHorizontal: 20, 207 - }, 208 - })