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