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