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