my fork of the bluesky client
at main 300 lines 8.2 kB view raw
1import React from 'react' 2import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native' 3import { 4 AppBskyFeedDefs, 5 AppBskyFeedPost, 6 AppBskyGraphDefs, 7 AtUri, 8} from '@atproto/api' 9import {msg, Trans} from '@lingui/macro' 10import {useLingui} from '@lingui/react' 11 12import {HITSLOP_10} from '#/lib/constants' 13import {makeListLink, makeProfileLink} from '#/lib/routes/links' 14import {isNative} from '#/platform/detection' 15import { 16 ThreadgateAllowUISetting, 17 threadgateViewToAllowUISetting, 18} from '#/state/queries/threadgate' 19import {atoms as a, useTheme} from '#/alf' 20import {Button} from '#/components/Button' 21import * as Dialog from '#/components/Dialog' 22import {useDialogControl} from '#/components/Dialog' 23import { 24 PostInteractionSettingsDialog, 25 usePrefetchPostInteractionSettings, 26} from '#/components/dialogs/PostInteractionSettingsDialog' 27import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 28import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' 29import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 30import {InlineLinkText} from '#/components/Link' 31import {Text} from '#/components/Typography' 32import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' 33 34interface WhoCanReplyProps { 35 post: AppBskyFeedDefs.PostView 36 isThreadAuthor: boolean 37 style?: StyleProp<ViewStyle> 38} 39 40export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { 41 const {_} = useLingui() 42 const t = useTheme() 43 const infoDialogControl = useDialogControl() 44 const editDialogControl = useDialogControl() 45 46 /* 47 * `WhoCanReply` is only used for root posts atm, in case this changes 48 * unexpectedly, we should check to make sure it's for sure the root URI. 49 */ 50 const rootUri = 51 AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root 52 ? post.record.reply.root.uri 53 : post.uri 54 const settings = React.useMemo(() => { 55 return threadgateViewToAllowUISetting(post.threadgate) 56 }, [post.threadgate]) 57 58 const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ 59 postUri: post.uri, 60 rootPostUri: rootUri, 61 }) 62 63 const anyoneCanReply = 64 settings.length === 1 && settings[0].type === 'everybody' 65 const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody' 66 const description = anyoneCanReply 67 ? _(msg`Everybody can reply`) 68 : noOneCanReply 69 ? _(msg`Replies disabled`) 70 : _(msg`Some people can reply`) 71 72 const onPressOpen = () => { 73 if (isNative && Keyboard.isVisible()) { 74 Keyboard.dismiss() 75 } 76 if (isThreadAuthor) { 77 editDialogControl.open() 78 } else { 79 infoDialogControl.open() 80 } 81 } 82 83 return ( 84 <> 85 <Button 86 label={ 87 isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`) 88 } 89 onPress={onPressOpen} 90 {...(isThreadAuthor 91 ? Platform.select({ 92 web: { 93 onHoverIn: prefetchPostInteractionSettings, 94 }, 95 native: { 96 onPressIn: prefetchPostInteractionSettings, 97 }, 98 }) 99 : {})} 100 hitSlop={HITSLOP_10}> 101 {({hovered}) => ( 102 <View style={[a.flex_row, a.align_center, a.gap_xs, style]}> 103 <Icon 104 color={t.palette.contrast_400} 105 width={16} 106 settings={settings} 107 /> 108 <Text 109 style={[ 110 a.text_sm, 111 a.leading_tight, 112 t.atoms.text_contrast_medium, 113 hovered && a.underline, 114 ]}> 115 {description} 116 </Text> 117 118 {isThreadAuthor && ( 119 <PencilLine width={12} fill={t.palette.primary_500} /> 120 )} 121 </View> 122 )} 123 </Button> 124 125 {isThreadAuthor ? ( 126 <PostInteractionSettingsDialog 127 postUri={post.uri} 128 rootPostUri={rootUri} 129 control={editDialogControl} 130 initialThreadgateView={post.threadgate} 131 /> 132 ) : ( 133 <WhoCanReplyDialog 134 control={infoDialogControl} 135 post={post} 136 settings={settings} 137 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 138 /> 139 )} 140 </> 141 ) 142} 143 144function Icon({ 145 color, 146 width, 147 settings, 148}: { 149 color: string 150 width?: number 151 settings: ThreadgateAllowUISetting[] 152}) { 153 const isEverybody = settings.length === 0 154 const isNobody = !!settings.find(gate => gate.type === 'nobody') 155 const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group 156 return <IconComponent fill={color} width={width} /> 157} 158 159function WhoCanReplyDialog({ 160 control, 161 post, 162 settings, 163 embeddingDisabled, 164}: { 165 control: Dialog.DialogControlProps 166 post: AppBskyFeedDefs.PostView 167 settings: ThreadgateAllowUISetting[] 168 embeddingDisabled: boolean 169}) { 170 const {_} = useLingui() 171 return ( 172 <Dialog.Outer control={control}> 173 <Dialog.Handle /> 174 <Dialog.ScrollableInner 175 label={_(msg`Dialog: adjust who can interact with this post`)} 176 style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> 177 <View style={[a.gap_sm]}> 178 <Text style={[a.font_bold, a.text_xl, a.pb_sm]}> 179 <Trans>Who can interact with this post?</Trans> 180 </Text> 181 <Rules 182 post={post} 183 settings={settings} 184 embeddingDisabled={embeddingDisabled} 185 /> 186 </View> 187 </Dialog.ScrollableInner> 188 </Dialog.Outer> 189 ) 190} 191 192function Rules({ 193 post, 194 settings, 195 embeddingDisabled, 196}: { 197 post: AppBskyFeedDefs.PostView 198 settings: ThreadgateAllowUISetting[] 199 embeddingDisabled: boolean 200}) { 201 const t = useTheme() 202 203 return ( 204 <> 205 <Text 206 style={[ 207 a.text_sm, 208 a.leading_snug, 209 a.flex_wrap, 210 t.atoms.text_contrast_medium, 211 ]}> 212 {settings[0].type === 'everybody' ? ( 213 <Trans>Everybody can reply to this post.</Trans> 214 ) : settings[0].type === 'nobody' ? ( 215 <Trans>Replies to this post are disabled.</Trans> 216 ) : ( 217 <Trans> 218 Only{' '} 219 {settings.map((rule, i) => ( 220 <React.Fragment key={`rule-${i}`}> 221 <Rule rule={rule} post={post} lists={post.threadgate!.lists} /> 222 <Separator i={i} length={settings.length} /> 223 </React.Fragment> 224 ))}{' '} 225 can reply. 226 </Trans> 227 )}{' '} 228 </Text> 229 {embeddingDisabled && ( 230 <Text 231 style={[ 232 a.text_sm, 233 a.leading_snug, 234 a.flex_wrap, 235 t.atoms.text_contrast_medium, 236 ]}> 237 <Trans>No one but the author can quote this post.</Trans> 238 </Text> 239 )} 240 </> 241 ) 242} 243 244function Rule({ 245 rule, 246 post, 247 lists, 248}: { 249 rule: ThreadgateAllowUISetting 250 post: AppBskyFeedDefs.PostView 251 lists: AppBskyGraphDefs.ListViewBasic[] | undefined 252}) { 253 if (rule.type === 'mention') { 254 return <Trans>mentioned users</Trans> 255 } 256 if (rule.type === 'following') { 257 return ( 258 <Trans> 259 users followed by{' '} 260 <InlineLinkText 261 label={`@${post.author.handle}`} 262 to={makeProfileLink(post.author)} 263 style={[a.text_sm, a.leading_snug]}> 264 @{post.author.handle} 265 </InlineLinkText> 266 </Trans> 267 ) 268 } 269 if (rule.type === 'list') { 270 const list = lists?.find(l => l.uri === rule.list) 271 if (list) { 272 const listUrip = new AtUri(list.uri) 273 return ( 274 <Trans> 275 <InlineLinkText 276 label={list.name} 277 to={makeListLink(listUrip.hostname, listUrip.rkey)} 278 style={[a.text_sm, a.leading_snug]}> 279 {list.name} 280 </InlineLinkText>{' '} 281 members 282 </Trans> 283 ) 284 } 285 } 286} 287 288function Separator({i, length}: {i: number; length: number}) { 289 if (length < 2 || i === length - 1) { 290 return null 291 } 292 if (i === length - 2) { 293 return ( 294 <> 295 {length > 2 ? ',' : ''} <Trans>and</Trans>{' '} 296 </> 297 ) 298 } 299 return <>, </> 300}